diff --git a/CMakeLists.txt b/CMakeLists.txt index 4043707..57ffef6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,7 @@ target_link_libraries(pp_platform_api add_library(pp_app_core STATIC src/app_core/app_preferences.h src/app_core/app_status.h + src/app_core/brush_ui.h src/app_core/document_animation.h src/app_core/document_cloud.h src/app_core/document_export.cpp diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 35a1f90..d941776 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -40,6 +40,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0020 | Open | Modernization | Document resize dialog state and selected-resolution planning now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, and `pano_cli plan-document-resize`, but live resize execution still calls legacy `Canvas::resize` and clears legacy `ActionManager` history directly | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by a document/app boundary with legacy `Canvas` acting only as an adapter or removed entirely | | DEBT-0021 | Open | Modernization | Layer rename and layer panel operation planning now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, and `pano_cli plan-layer-operation`, but live execution still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely | | DEBT-0022 | Open | Modernization | Animation panel frame command planning now consumes pure `pp_app_core` through `NodePanelAnimation` and `pano_cli plan-animation-operation`, and `pp_legacy_ui_core` temporarily links `pp_app_core`, but live execution still mutates legacy `Canvas`/`Layer` frame state and animation playback state directly | Preserve existing animation panel behavior while timeline/frame commands move toward the document/app command boundary | `pp_app_core_document_animation_tests`; `pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0`; `pano_cli plan-animation-operation --kind next --total-duration 5 --current-frame 4`; `ctest --preset desktop-fast --build-config Debug` | Animation frame/timeline execution is owned by the document/app command boundary with legacy `Canvas`/`Layer`/UI nodes acting only as adapters or removed entirely | +| DEBT-0023 | Open | Modernization | Brush/color/preset UI planning now consumes pure `pp_app_core` through `App::init_sidebar`, restored/docked floating-panel callbacks, and `pano_cli plan-brush-operation`, but live execution still mutates legacy `Brush`, calls legacy brush texture loading, and refreshes legacy quick/stroke/color widgets directly | Preserve existing brush UI behavior while brush commands move toward a brush/app command boundary and asset-managed texture selection | `pp_app_core_brush_ui_tests`; `pano_cli plan-brush-operation --kind color --r 0.25 --g 0.5 --b 0.75 --a 1`; `pano_cli plan-brush-operation --kind pattern --path data/patterns/noise.png --thumb data/patterns/thumbs/noise.png`; `ctest --preset desktop-fast --build-config Debug` | Brush color/texture/preset execution is owned by a brush/app command boundary with legacy `Brush`/UI nodes acting only as adapters or removed entirely | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 699af96..442aff7 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -494,6 +494,10 @@ UI layer execution continue. frame add, duplicate, remove, duration adjustment, timeline moves, timeline goto/next/previous, and onion-size updates used by the live animation panel before legacy `Canvas`/`Layer` frame execution continues. +`pano_cli plan-brush-operation` exposes app-core planning for brush color +changes, tip/pattern/dual texture changes, preset brush replacement, and stroke +settings refreshes used by the live brush, quick, color, and floating panel +callbacks before legacy `Brush` mutation and resource loading continue. `pp_platform_api` now owns a headless `PlatformServices` interface for startup storage path preparation, clipboard text, cursor visibility, virtual-keyboard visibility, UI-thread lifecycle hooks, render-context @@ -1123,6 +1127,15 @@ Results: `pano_cli_plan_animation_operation_next_wrap_smoke`, and `pano_cli_plan_animation_operation_rejects_remove_last_frame` passed and expose live animation-panel planning as JSON automation. +- `pp_app_core_brush_ui_tests` passed, covering brush color channel validation, + invalid color rejection, texture-path validation, preset-brush availability, + preserve-current-color intent, and stroke-settings refresh intent. +- `pano_cli_plan_brush_operation_color_smoke`, + `pano_cli_plan_brush_operation_texture_smoke`, + `pano_cli_plan_brush_operation_preset_smoke`, + `pano_cli_plan_brush_operation_rejects_bad_color`, and + `pano_cli_plan_brush_operation_rejects_empty_texture` passed and expose live + brush/color/preset UI planning as JSON automation. - `pp_app_core_document_sharing_tests` passed, covering saved-path gating before platform share execution. - `pano_cli_plan_share_file_unsaved_smoke` and diff --git a/src/app_core/brush_ui.h b/src/app_core/brush_ui.h new file mode 100644 index 0000000..5060041 --- /dev/null +++ b/src/app_core/brush_ui.h @@ -0,0 +1,122 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include + +namespace pp::app { + +enum class BrushUiTextureSlot { + tip, + pattern, + dual, +}; + +enum class BrushUiOperation { + set_tip_color, + set_texture, + replace_brush_from_preset, + stroke_settings_changed, +}; + +struct BrushUiPlan { + BrushUiOperation operation = BrushUiOperation::stroke_settings_changed; + BrushUiTextureSlot texture_slot = BrushUiTextureSlot::tip; + std::string path; + std::string thumbnail_path; + float r = 0.0F; + float g = 0.0F; + float b = 0.0F; + float a = 1.0F; + bool mutates_brush = false; + bool preserves_existing_color = false; + bool loads_brush_resources = false; + bool update_color_ui = false; + bool update_brush_ui = false; +}; + +[[nodiscard]] inline pp::foundation::Status validate_brush_ui_color_channel(float value) noexcept +{ + if (!std::isfinite(value) || value < 0.0F || value > 1.0F) { + return pp::foundation::Status::out_of_range("brush color channels must be finite and within 0..1"); + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] inline pp::foundation::Result plan_brush_ui_color( + float r, + float g, + float b, + float a) +{ + for (const auto value : { r, g, b, a }) { + const auto channel_status = validate_brush_ui_color_channel(value); + if (!channel_status.ok()) { + return pp::foundation::Result::failure(channel_status); + } + } + + BrushUiPlan plan; + plan.operation = BrushUiOperation::set_tip_color; + plan.r = r; + plan.g = g; + plan.b = b; + plan.a = a; + plan.mutates_brush = true; + plan.update_color_ui = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_brush_ui_texture( + BrushUiTextureSlot slot, + std::string_view path, + std::string_view thumbnail_path) +{ + if (path.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("brush texture path must not be empty")); + } + + BrushUiPlan plan; + plan.operation = BrushUiOperation::set_texture; + plan.texture_slot = slot; + plan.path = std::string(path); + plan.thumbnail_path = std::string(thumbnail_path); + plan.mutates_brush = true; + plan.loads_brush_resources = true; + plan.update_color_ui = true; + plan.update_brush_ui = true; + return pp::foundation::Result::success(std::move(plan)); +} + +[[nodiscard]] inline pp::foundation::Result plan_brush_ui_preset_replace(bool has_preset_brush) +{ + if (!has_preset_brush) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("preset brush must be available")); + } + + BrushUiPlan plan; + plan.operation = BrushUiOperation::replace_brush_from_preset; + plan.mutates_brush = true; + plan.preserves_existing_color = true; + plan.loads_brush_resources = true; + plan.update_color_ui = true; + plan.update_brush_ui = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline constexpr BrushUiPlan plan_brush_ui_stroke_settings_changed() noexcept +{ + BrushUiPlan plan; + plan.operation = BrushUiOperation::stroke_settings_changed; + plan.mutates_brush = true; + plan.update_color_ui = true; + plan.update_brush_ui = true; + return plan; +} + +} // namespace pp::app diff --git a/src/app_layout.cpp b/src/app_layout.cpp index f8a495d..9a286d7 100644 --- a/src/app_layout.cpp +++ b/src/app_layout.cpp @@ -7,6 +7,7 @@ #include "node_dialog_picker.h" #include "node_panel_floating.h" #include "app_core/app_preferences.h" +#include "app_core/brush_ui.h" #include "app_core/document_layer.h" #include "app_core/app_status.h" #include "settings.h" @@ -17,6 +18,64 @@ #include +namespace { + +bool apply_brush_color_plan(App& app, glm::vec4 color, bool update_quick, bool update_color_panel) +{ + const auto plan = pp::app::plan_brush_ui_color(color.r, color.g, color.b, color.a); + if (!plan) + return false; + Canvas::I->m_current_brush->m_tip_color = glm::vec4( + plan.value().r, + plan.value().g, + plan.value().b, + plan.value().a); + if (update_quick) + app.quick->set_color(Canvas::I->m_current_brush->m_tip_color); + if (update_color_panel) + app.color->set_color(Canvas::I->m_current_brush->m_tip_color); + app.brush_update(plan.value().update_color_ui, plan.value().update_brush_ui); + return true; +} + +bool apply_brush_texture_plan(pp::app::BrushUiTextureSlot slot, const std::string& path, const std::string& thumb) +{ + const auto plan = pp::app::plan_brush_ui_texture(slot, path, thumb); + if (!plan) + return false; + switch (plan.value().texture_slot) + { + case pp::app::BrushUiTextureSlot::tip: + Canvas::I->m_current_brush->load_tip(plan.value().path, plan.value().thumbnail_path); + break; + case pp::app::BrushUiTextureSlot::pattern: + Canvas::I->m_current_brush->load_pattern(plan.value().path, plan.value().thumbnail_path); + break; + case pp::app::BrushUiTextureSlot::dual: + Canvas::I->m_current_brush->load_dual(plan.value().path, plan.value().thumbnail_path); + break; + } + App::I->brush_update(plan.value().update_color_ui, plan.value().update_brush_ui); + return true; +} + +bool apply_brush_preset_plan(App& app, const std::shared_ptr& brush) +{ + const auto plan = pp::app::plan_brush_ui_preset_replace(static_cast(brush)); + if (!plan) + return false; + auto color = Canvas::I->m_current_brush->m_tip_color; + *Canvas::I->m_current_brush = *brush; + if (plan.value().preserves_existing_color) + Canvas::I->m_current_brush->m_tip_color = color; + if (plan.value().loads_brush_resources) + Canvas::I->m_current_brush->load(); + app.brush_update(plan.value().update_color_ui, plan.value().update_brush_ui); + return true; +} + +} // namespace + void App::title_update() { if (auto docname = layout[main_id]->find("txt-docname")) @@ -144,30 +203,25 @@ void App::init_sidebar() brush_update(true, true); }; color->on_color_changed = [this](Node* target, glm::vec4 color) { - Canvas::I->m_current_brush->m_tip_color = color; - quick->set_color(color); - brush_update(true, false); + apply_brush_color_plan(*this, color, true, false); }; stroke->on_brush_changed = [this](Node* target, const std::string& path, const std::string& thumb) { - Canvas::I->m_current_brush->load_tip(path, thumb); - brush_update(true, true); + apply_brush_texture_plan(pp::app::BrushUiTextureSlot::tip, path, thumb); }; stroke->on_pattern_changed = [this](Node*target, const std::string& path, const std::string& thumb) { - Canvas::I->m_current_brush->load_pattern(path, thumb); - brush_update(true, true); + apply_brush_texture_plan(pp::app::BrushUiTextureSlot::pattern, path, thumb); }; stroke->on_dual_changed = [this](Node*target, const std::string& path, const std::string& thumb) { - Canvas::I->m_current_brush->load_dual(path, thumb); - brush_update(true, true); + apply_brush_texture_plan(pp::app::BrushUiTextureSlot::dual, path, thumb); }; stroke->on_stroke_change = [this](Node*) { - brush_update(true, true); + const auto plan = pp::app::plan_brush_ui_stroke_settings_changed(); + brush_update(plan.update_color_ui, plan.update_brush_ui); }; quick->on_color_change = [this](Node*, glm::vec3 c) { - Canvas::I->m_current_brush->m_tip_color = glm::vec4(c, 1.f); - color->set_color(c); + apply_brush_color_plan(*this, glm::vec4(c, 1.f), false, true); }; quick->on_flow_change = [this](Node*, float value) { stroke->set_flow(value, true, true); @@ -176,10 +230,7 @@ void App::init_sidebar() stroke->set_size(value, true, true); }; quick->on_brush_change = [this](Node*, std::shared_ptr b) { - auto c = Canvas::I->m_current_brush->m_tip_color; - *Canvas::I->m_current_brush = *b; - Canvas::I->m_current_brush->m_tip_color = c; - brush_update(true, true); + apply_brush_preset_plan(*this, b); }; layers->on_layer_add = [this](Node*, std::shared_ptr layer, int index) { @@ -853,11 +904,7 @@ void App::init_menu_tools() //floating_presets->SetFlexGrow(1); //floating_presets->find("toolbar")->destroy(); floating_presets->on_brush_changed = [this](Node* target, std::shared_ptr& b) { - auto c = Canvas::I->m_current_brush->m_tip_color; - *Canvas::I->m_current_brush = *b; - Canvas::I->m_current_brush->m_tip_color = c; - Canvas::I->m_current_brush->load(); - brush_update(true, true); + apply_brush_preset_plan(*this, b); }; } else @@ -883,8 +930,7 @@ void App::init_menu_tools() //floating_color->SetMinHeight(300); floating_color->find("title")->SetVisibility(false); floating_color->on_color_changed = [this](Node* target, glm::vec4 color) { - Canvas::I->m_current_brush->m_tip_color = color; - brush_update(true, false); + apply_brush_color_plan(*this, color, false, false); }; } else @@ -910,8 +956,7 @@ void App::init_menu_tools() //floating_picker->SetWidth(250); floating_picker->m_autohide = false; floating_picker->on_color_change = [this](Node* target, glm::vec3 color) { - Canvas::I->m_current_brush->m_tip_color = glm::vec4(color, 1.f); - brush_update(true, false); + apply_brush_color_plan(*this, glm::vec4(color, 1.f), false, false); }; } else @@ -1624,11 +1669,7 @@ void App::ui_restore() floating_presets->SetHeightP(100); //floating_presets->find("toolbar")->destroy(); floating_presets->on_brush_changed = [this](Node* target, std::shared_ptr& b) { - auto c = Canvas::I->m_current_brush->m_tip_color; - *Canvas::I->m_current_brush = *b; - Canvas::I->m_current_brush->m_tip_color = c; - Canvas::I->m_current_brush->load(); - brush_update(true, true); + apply_brush_preset_plan(*this, b); }; break; } @@ -1638,8 +1679,7 @@ void App::ui_restore() floating_color->SetHeightP(100); floating_color->find("title")->SetVisibility(false); floating_color->on_color_changed = [this](Node* target, glm::vec4 color) { - Canvas::I->m_current_brush->m_tip_color = color; - brush_update(true, false); + apply_brush_color_plan(*this, color, false, false); }; break; } @@ -1648,8 +1688,7 @@ void App::ui_restore() floating_picker = f->m_container->add_child_ref(); floating_picker->m_autohide = false; floating_picker->on_color_change = [this](Node* target, glm::vec3 color) { - Canvas::I->m_current_brush->m_tip_color = glm::vec4(color, 1.f); - brush_update(true, false); + apply_brush_color_plan(*this, glm::vec4(color, 1.f), false, false); }; break; } @@ -1717,11 +1756,7 @@ void App::ui_restore() floating_presets->SetHeightP(100); //floating_presets->find("toolbar")->destroy(); floating_presets->on_brush_changed = [this](Node* target, std::shared_ptr& b) { - auto c = Canvas::I->m_current_brush->m_tip_color; - *Canvas::I->m_current_brush = *b; - Canvas::I->m_current_brush->m_tip_color = c; - Canvas::I->m_current_brush->load(); - brush_update(true, true); + apply_brush_preset_plan(*this, b); }; break; } @@ -1731,8 +1766,7 @@ void App::ui_restore() floating_color->SetHeightP(100); floating_color->find("title")->destroy(); floating_color->on_color_changed = [this](Node* target, glm::vec4 color) { - Canvas::I->m_current_brush->m_tip_color = color; - brush_update(true, false); + apply_brush_color_plan(*this, color, false, false); }; break; } @@ -1741,8 +1775,7 @@ void App::ui_restore() floating_picker = f->m_container->add_child_ref(); floating_picker->m_autohide = false; floating_picker->on_color_change = [this](Node* target, glm::vec3 color) { - Canvas::I->m_current_brush->m_tip_color = glm::vec4(color, 1.f); - brush_update(true, false); + apply_brush_color_plan(*this, glm::vec4(color, 1.f), false, false); }; break; } @@ -1797,11 +1830,7 @@ void App::ui_restore() floating_presets->SetHeightP(100); //floating_presets->find("toolbar")->destroy(); floating_presets->on_brush_changed = [this](Node* target, std::shared_ptr& b) { - auto c = Canvas::I->m_current_brush->m_tip_color; - *Canvas::I->m_current_brush = *b; - Canvas::I->m_current_brush->m_tip_color = c; - Canvas::I->m_current_brush->load(); - brush_update(true, true); + apply_brush_preset_plan(*this, b); }; break; } @@ -1811,8 +1840,7 @@ void App::ui_restore() floating_color->SetHeightP(100); floating_color->find("title")->destroy(); floating_color->on_color_changed = [this](Node* target, glm::vec4 color) { - Canvas::I->m_current_brush->m_tip_color = color; - brush_update(true, false); + apply_brush_color_plan(*this, color, false, false); }; break; } @@ -1821,8 +1849,7 @@ void App::ui_restore() floating_picker = f->m_container->add_child_ref(); floating_picker->m_autohide = false; floating_picker->on_color_change = [this](Node* target, glm::vec3 color) { - Canvas::I->m_current_brush->m_tip_color = glm::vec4(color, 1.f); - brush_update(true, false); + apply_brush_color_plan(*this, glm::vec4(color, 1.f), false, false); }; break; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 34eb225..a92f501 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -268,6 +268,16 @@ add_test(NAME pp_ui_core_layout_xml_tests COMMAND pp_ui_core_layout_xml_tests) set_tests_properties(pp_ui_core_layout_xml_tests PROPERTIES LABELS "ui;desktop-fast;fuzz") +add_executable(pp_app_core_brush_ui_tests + app_core/brush_ui_tests.cpp) +target_link_libraries(pp_app_core_brush_ui_tests PRIVATE + pp_app_core + pp_test_harness) + +add_test(NAME pp_app_core_brush_ui_tests COMMAND pp_app_core_brush_ui_tests) +set_tests_properties(pp_app_core_brush_ui_tests PROPERTIES + LABELS "app;paint;desktop-fast;fuzz") + add_executable(pp_app_core_document_route_tests app_core/document_route_tests.cpp) target_link_libraries(pp_app_core_document_route_tests PRIVATE @@ -776,6 +786,36 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_brush_operation_color_smoke + COMMAND pano_cli plan-brush-operation --kind color --r 0.25 --g 0.5 --b 0.75 --a 1) + set_tests_properties(pano_cli_plan_brush_operation_color_smoke PROPERTIES + LABELS "app;paint;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-brush-operation\".*\"operation\":\"set-tip-color\".*\"r\":0.25.*\"g\":0.5.*\"b\":0.75.*\"updateColorUi\":true.*\"updateBrushUi\":false") + + add_test(NAME pano_cli_plan_brush_operation_texture_smoke + COMMAND pano_cli plan-brush-operation --kind pattern --path data/patterns/noise.png --thumb data/patterns/thumbs/noise.png) + set_tests_properties(pano_cli_plan_brush_operation_texture_smoke PROPERTIES + LABELS "app;paint;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-brush-operation\".*\"operation\":\"set-texture\".*\"textureSlot\":\"pattern\".*\"path\":\"data/patterns/noise.png\".*\"loadsBrushResources\":true.*\"updateBrushUi\":true") + + add_test(NAME pano_cli_plan_brush_operation_preset_smoke + COMMAND pano_cli plan-brush-operation --kind preset) + set_tests_properties(pano_cli_plan_brush_operation_preset_smoke PROPERTIES + LABELS "app;paint;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-brush-operation\".*\"operation\":\"replace-brush-from-preset\".*\"preservesExistingColor\":true.*\"loadsBrushResources\":true") + + add_test(NAME pano_cli_plan_brush_operation_rejects_bad_color + COMMAND pano_cli plan-brush-operation --kind color --r 1.5) + set_tests_properties(pano_cli_plan_brush_operation_rejects_bad_color PROPERTIES + LABELS "app;paint;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + + add_test(NAME pano_cli_plan_brush_operation_rejects_empty_texture + COMMAND pano_cli plan-brush-operation --kind tip) + set_tests_properties(pano_cli_plan_brush_operation_rejects_empty_texture PROPERTIES + LABELS "app;paint;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_share_file_unsaved_smoke COMMAND pano_cli plan-share-file) set_tests_properties(pano_cli_plan_share_file_unsaved_smoke PROPERTIES diff --git a/tests/app_core/brush_ui_tests.cpp b/tests/app_core/brush_ui_tests.cpp new file mode 100644 index 0000000..46d972b --- /dev/null +++ b/tests/app_core/brush_ui_tests.cpp @@ -0,0 +1,85 @@ +#include "app_core/brush_ui.h" +#include "test_harness.h" + +#include + +namespace { + +void color_plan_validates_all_channels(pp::tests::Harness& harness) +{ + const auto plan = pp::app::plan_brush_ui_color(0.25F, 0.5F, 0.75F, 1.0F); + PP_EXPECT(harness, plan); + if (plan) { + PP_EXPECT(harness, plan.value().operation == pp::app::BrushUiOperation::set_tip_color); + PP_EXPECT(harness, plan.value().r == 0.25F); + PP_EXPECT(harness, plan.value().g == 0.5F); + PP_EXPECT(harness, plan.value().b == 0.75F); + PP_EXPECT(harness, plan.value().a == 1.0F); + PP_EXPECT(harness, plan.value().mutates_brush); + PP_EXPECT(harness, plan.value().update_color_ui); + PP_EXPECT(harness, !plan.value().update_brush_ui); + } + + PP_EXPECT(harness, !pp::app::plan_brush_ui_color(-0.01F, 0.5F, 0.5F, 1.0F)); + PP_EXPECT(harness, !pp::app::plan_brush_ui_color(0.5F, 1.01F, 0.5F, 1.0F)); + PP_EXPECT(harness, !pp::app::plan_brush_ui_color(0.5F, 0.5F, std::nanf(""), 1.0F)); +} + +void texture_plan_validates_path_and_slot(pp::tests::Harness& harness) +{ + const auto plan = pp::app::plan_brush_ui_texture( + pp::app::BrushUiTextureSlot::pattern, + "data/patterns/noise.png", + "data/patterns/thumbs/noise.png"); + PP_EXPECT(harness, plan); + if (plan) { + PP_EXPECT(harness, plan.value().operation == pp::app::BrushUiOperation::set_texture); + PP_EXPECT(harness, plan.value().texture_slot == pp::app::BrushUiTextureSlot::pattern); + PP_EXPECT(harness, plan.value().path == "data/patterns/noise.png"); + PP_EXPECT(harness, plan.value().thumbnail_path == "data/patterns/thumbs/noise.png"); + PP_EXPECT(harness, plan.value().loads_brush_resources); + PP_EXPECT(harness, plan.value().update_color_ui); + PP_EXPECT(harness, plan.value().update_brush_ui); + } + + PP_EXPECT( + harness, + !pp::app::plan_brush_ui_texture(pp::app::BrushUiTextureSlot::tip, "", "thumb.png")); +} + +void preset_plan_preserves_color_and_requires_brush(pp::tests::Harness& harness) +{ + const auto plan = pp::app::plan_brush_ui_preset_replace(true); + PP_EXPECT(harness, plan); + if (plan) { + PP_EXPECT(harness, plan.value().operation == pp::app::BrushUiOperation::replace_brush_from_preset); + PP_EXPECT(harness, plan.value().preserves_existing_color); + PP_EXPECT(harness, plan.value().loads_brush_resources); + PP_EXPECT(harness, plan.value().update_color_ui); + PP_EXPECT(harness, plan.value().update_brush_ui); + } + + PP_EXPECT(harness, !pp::app::plan_brush_ui_preset_replace(false)); +} + +void stroke_settings_plan_updates_brush_preview(pp::tests::Harness& harness) +{ + const auto plan = pp::app::plan_brush_ui_stroke_settings_changed(); + PP_EXPECT(harness, plan.operation == pp::app::BrushUiOperation::stroke_settings_changed); + PP_EXPECT(harness, plan.mutates_brush); + PP_EXPECT(harness, plan.update_color_ui); + PP_EXPECT(harness, plan.update_brush_ui); + PP_EXPECT(harness, !plan.loads_brush_resources); +} + +} // namespace + +int main() +{ + pp::tests::Harness harness; + harness.run("color plan validates all channels", color_plan_validates_all_channels); + harness.run("texture plan validates path and slot", texture_plan_validates_path_and_slot); + harness.run("preset plan preserves color and requires brush", preset_plan_preserves_color_and_requires_brush); + harness.run("stroke settings plan updates brush preview", stroke_settings_plan_updates_brush_preview); + return harness.finish(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 44d6c53..0dc6533 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -1,5 +1,6 @@ #include "app_core/app_preferences.h" #include "app_core/app_status.h" +#include "app_core/brush_ui.h" #include "app_core/document_animation.h" #include "app_core/document_export.h" #include "app_core/document_cloud.h" @@ -251,6 +252,17 @@ struct PlanAnimationOperationArgs { int onion_size = 1; }; +struct PlanBrushOperationArgs { + std::string kind = "settings"; + std::string path; + std::string thumbnail_path; + float r = 1.0F; + float g = 1.0F; + float b = 1.0F; + float a = 1.0F; + bool has_brush = true; +}; + struct SimulateAppSessionArgs { bool has_canvas = true; bool new_document = false; @@ -550,6 +562,36 @@ const char* document_animation_operation_name(pp::app::DocumentAnimationOperatio return "goto-frame"; } +const char* brush_ui_texture_slot_name(pp::app::BrushUiTextureSlot slot) noexcept +{ + switch (slot) { + case pp::app::BrushUiTextureSlot::tip: + return "tip"; + case pp::app::BrushUiTextureSlot::pattern: + return "pattern"; + case pp::app::BrushUiTextureSlot::dual: + return "dual"; + } + + return "tip"; +} + +const char* brush_ui_operation_name(pp::app::BrushUiOperation operation) noexcept +{ + switch (operation) { + case pp::app::BrushUiOperation::set_tip_color: + return "set-tip-color"; + case pp::app::BrushUiOperation::set_texture: + return "set-texture"; + case pp::app::BrushUiOperation::replace_brush_from_preset: + return "replace-brush-from-preset"; + case pp::app::BrushUiOperation::stroke_settings_changed: + return "stroke-settings-changed"; + } + + return "stroke-settings-changed"; +} + const char* document_file_write_decision_name(pp::app::DocumentFileWriteDecision decision) noexcept { switch (decision) { @@ -804,6 +846,7 @@ void print_help() << " plan-layer-rename --old-name NAME --new-name NAME\n" << " plan-layer-operation --kind add|duplicate|select|reorder|remove|opacity|visibility|alpha-lock|blend-mode|highlight [--layer-count N] [--index N] [--from-index N] [--to-index N] [--source-index N] [--name NAME] [--opacity N] [--blend-mode N] [--enabled]\n" << " plan-animation-operation --kind add|duplicate|remove|duration|move|goto|next|prev|onion [--frame-count N] [--total-duration N] [--current-frame N] [--selected-frame N] [--current-duration N] [--delta N] [--offset N] [--onion-size N]\n" + << " plan-brush-operation --kind color|tip|pattern|dual|preset|settings [--path FILE] [--thumb FILE] [--r N] [--g N] [--b N] [--a N] [--no-brush]\n" << " plan-share-file [--path FILE]\n" << " plan-picked-path [--path FILE]\n" << " plan-display-file [--path FILE]\n" @@ -2719,6 +2762,129 @@ int plan_animation_operation(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_brush_operation_args( + int argc, + char** argv, + PlanBrushOperationArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--kind" || key == "--path" || key == "--thumb") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + if (key == "--kind") { + args.kind = argv[++i]; + } else if (key == "--path") { + args.path = argv[++i]; + } else { + args.thumbnail_path = argv[++i]; + } + } else if (key == "--r" || key == "--g" || key == "--b" || key == "--a") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_float_arg(argv[++i]); + if (!value) { + return value.status(); + } + if (key == "--r") { + args.r = value.value(); + } else if (key == "--g") { + args.g = value.value(); + } else if (key == "--b") { + args.b = value.value(); + } else { + args.a = value.value(); + } + } else if (key == "--no-brush") { + args.has_brush = false; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +pp::foundation::Result make_brush_operation_plan( + const PlanBrushOperationArgs& args) +{ + if (args.kind == "color") { + return pp::app::plan_brush_ui_color(args.r, args.g, args.b, args.a); + } + if (args.kind == "tip") { + return pp::app::plan_brush_ui_texture( + pp::app::BrushUiTextureSlot::tip, + args.path, + args.thumbnail_path); + } + if (args.kind == "pattern") { + return pp::app::plan_brush_ui_texture( + pp::app::BrushUiTextureSlot::pattern, + args.path, + args.thumbnail_path); + } + if (args.kind == "dual") { + return pp::app::plan_brush_ui_texture( + pp::app::BrushUiTextureSlot::dual, + args.path, + args.thumbnail_path); + } + if (args.kind == "preset") { + return pp::app::plan_brush_ui_preset_replace(args.has_brush); + } + if (args.kind == "settings") { + return pp::foundation::Result::success( + pp::app::plan_brush_ui_stroke_settings_changed()); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown brush operation kind")); +} + +int plan_brush_operation(int argc, char** argv) +{ + PlanBrushOperationArgs args; + const auto status = parse_plan_brush_operation_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-brush-operation", status.message); + return 2; + } + + const auto plan = make_brush_operation_plan(args); + if (!plan) { + print_error("plan-brush-operation", plan.status().message); + return 2; + } + + const auto& value = plan.value(); + std::cout << "{\"ok\":true,\"command\":\"plan-brush-operation\"" + << ",\"state\":{\"kind\":\"" << json_escape(args.kind) + << "\",\"path\":\"" << json_escape(args.path) + << "\",\"thumb\":\"" << json_escape(args.thumbnail_path) + << "\",\"r\":" << args.r + << ",\"g\":" << args.g + << ",\"b\":" << args.b + << ",\"a\":" << args.a + << ",\"hasBrush\":" << json_bool(args.has_brush) + << "},\"plan\":{\"operation\":\"" << brush_ui_operation_name(value.operation) + << "\",\"textureSlot\":\"" << brush_ui_texture_slot_name(value.texture_slot) + << "\",\"path\":\"" << json_escape(value.path) + << "\",\"thumb\":\"" << json_escape(value.thumbnail_path) + << "\",\"r\":" << value.r + << ",\"g\":" << value.g + << ",\"b\":" << value.b + << ",\"a\":" << value.a + << ",\"mutatesBrush\":" << json_bool(value.mutates_brush) + << ",\"preservesExistingColor\":" << json_bool(value.preserves_existing_color) + << ",\"loadsBrushResources\":" << json_bool(value.loads_brush_resources) + << ",\"updateColorUi\":" << json_bool(value.update_color_ui) + << ",\"updateBrushUi\":" << json_bool(value.update_brush_ui) + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_share_file_args( int argc, char** argv, @@ -5135,6 +5301,10 @@ int main(int argc, char** argv) return plan_animation_operation(argc, argv); } + if (command == "plan-brush-operation") { + return plan_brush_operation(argc, argv); + } + if (command == "plan-share-file") { return plan_share_file(argc, argv); }