From fb844f79fdd4fd39e0c467d13d183633a171e930 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 3 Jun 2026 12:05:13 +0200 Subject: [PATCH] Extract layer menu action planning --- docs/modernization/debt.md | 3 +- docs/modernization/roadmap.md | 14 ++- src/app_core/document_layer.h | 82 +++++++++++++ src/app_layout.cpp | 92 ++++++++------ tests/CMakeLists.txt | 24 ++++ tests/app_core/document_layer_tests.cpp | 112 +++++++++++++++++ tools/pano_cli/main.cpp | 153 ++++++++++++++++++++++++ 7 files changed, 440 insertions(+), 40 deletions(-) diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index be8e3ba..ae1ce85 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -22,7 +22,7 @@ agent or engineer to remove them without reconstructing context from chat. | --- | --- | --- | --- | --- | --- | --- | | DEBT-0001 | Open | Modernization | Existing platform build files remain alongside new CMake | Required for incremental migration without losing platform coverage | Existing platform builds plus new CMake configure | Remove after all platform builds consume shared CMake targets | | DEBT-0002 | Open | Modernization | Vendored SDK and patched libraries retained initially | Some dependencies are SDK-only, patched, or have platform-specific binaries | Dependency inventory and platform build smoke tests | Replace with vcpkg packages or document permanent vendored status after triplet evaluation | -| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display decisions, document resize decisions, layer rename decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/share/platform-I/O/display/keyboard/cloud/resize/layer contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, status/display UI rendering, document resize execution, layer rename execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4 execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | +| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display decisions, document resize decisions, layer rename/menu decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-menu`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/share/platform-I/O/display/keyboard/cloud/resize/layer contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, status/display UI rendering, document resize execution, layer rename/menu execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4 execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | | DEBT-0004 | Open | Modernization | Android, Linux, WebGL, Apple, and AppX build files remain platform-specific until root CMake alignment reaches them | Prevent platform regressions during incremental migration; raw Windows `.sln/.vcxproj` files were removed on 2026-05-31 by user decision | `cmake --preset windows-msvc-default`; platform-specific configure/build smoke checks as each platform is migrated | Root CMake owns every platform source list and package path | | DEBT-0005 | Open | Modernization | Temporary local CTest harness is used before Catch2 is wired through vcpkg | `vcpkg` is not currently on PATH, but headless tests need to run now | `ctest --preset desktop-fast --build-config Debug` | Replace `tests/test_harness.h` tests with Catch2 tests once vcpkg toolchain/presets are validated | | DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions | @@ -49,6 +49,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0029 | Open | Modernization | Image import route planning now consumes pure `pp_app_core` through the File menu and `pano_cli plan-image-import`, but live execution still calls legacy `Canvas::import_equirectangular` or legacy import transform mode setup directly after image loading | Preserve current File > Import behavior while image import moves toward document/app/asset command services | `pp_app_core_document_import_tests`; `pano_cli plan-image-import --width 4096 --height 2048`; `pano_cli plan-image-import --width 1024 --height 1024`; `ctest --preset desktop-fast --build-config Debug` | Image loading, equirectangular import, transform-placement import, and failure reporting are owned by document/app/asset services with File-menu callbacks acting only as adapters | | DEBT-0030 | Open | Modernization | File export menu action planning now consumes pure `pp_app_core` through the File menu and `pano_cli plan-export-menu`, but live execution still opens legacy export dialogs and then reaches legacy canvas/render/video export code | Preserve current export menu behavior while export command execution moves toward document/app/renderer/video services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind png`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-menu --kind layers --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Export menu routing, license gating, target creation, image/layer/cube/depth/animation/timelapse execution, and error reporting are owned by document/app services with File-menu callbacks acting only as adapters | | DEBT-0031 | Open | Modernization | Top-level File menu command planning now consumes pure `pp_app_core` through `App::init_menu_file` and `pano_cli plan-file-menu`, but live execution still invokes legacy dialogs, platform pickers, cloud code, share code, and canvas import/export paths directly | Preserve File menu behavior while app workflows move toward app/document/platform command services | `pp_app_core_file_menu_tests`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-file-menu --command import`; `pano_cli plan-file-menu --command cloud-upload`; `ctest --preset desktop-fast --build-config Debug` | File menu routing, picker dispatch, save/share/cloud/resize/export execution, and image/project import execution are owned by app/document/platform services with `App::init_menu_file` acting only as a UI adapter | +| DEBT-0032 | Open | Modernization | Layer menu command planning now consumes pure `pp_app_core` through `App::init_menu_layer` and `pano_cli plan-layer-menu`, but live execution still calls legacy `Canvas::clear`, `App::dialog_layer_rename`, `NodePanelLayer::merge`, and reads `Canvas::I` animation/layer state directly | Preserve existing Layer menu behavior while layer commands move toward document/app services | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-layer-menu --command rename --no-current-layer`; `ctest --preset desktop-fast --build-config Debug` | Layer clear, rename, merge-down execution, animation gating, and selected-layer state are owned by document/app services with Layer-menu callbacks acting only as UI adapters | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index ad47b23..0cfe2c1 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -491,6 +491,9 @@ the live layer rename dialog before legacy `Canvas` layer mutation and duplicate, select, reorder, remove, opacity, visibility, alpha-lock, blend-mode, and highlight actions used by the live layer panel before legacy `Canvas` and UI layer execution continue. +`pano_cli plan-layer-menu` exposes app-core planning for Layer menu clear, +rename, and merge-down labels/actions before legacy canvas/layer execution +continues. `pano_cli plan-animation-operation` exposes app-core planning for animation frame add, duplicate, remove, duration adjustment, timeline moves, timeline goto/next/previous, and onion-size updates used by the live animation panel @@ -1144,12 +1147,19 @@ Results: - `pp_app_core_document_layer_tests` passed, covering changed layer rename, unchanged no-op rename, empty-name rejection, overlong-name rejection, layer add/duplicate/select/reorder/remove planning, metadata planning, bad-index - rejection, bad-opacity rejection, bad-blend-mode rejection, and transient - highlight behavior. + rejection, bad-opacity rejection, bad-blend-mode rejection, transient + highlight behavior, Layer menu labels/actions, merge-down routing, animated + merge blocking, missing selection handling, and bad Layer menu state + rejection. - `pano_cli_plan_layer_rename_smoke`, `pano_cli_plan_layer_rename_no_op_smoke`, and `pano_cli_plan_layer_rename_rejects_empty_name` passed and expose live layer-rename planning as JSON automation. +- `pano_cli_plan_layer_menu_merge_smoke`, + `pano_cli_plan_layer_menu_merge_animated_blocked_smoke`, + `pano_cli_plan_layer_menu_missing_selection_smoke`, and + `pano_cli_plan_layer_menu_rejects_bad_state` passed and expose live Layer + menu planning as JSON automation. - `pano_cli_plan_layer_operation_add_smoke`, `pano_cli_plan_layer_operation_reorder_no_op_smoke`, `pano_cli_plan_layer_operation_highlight_smoke`, and diff --git a/src/app_core/document_layer.h b/src/app_core/document_layer.h index deab20d..11b991b 100644 --- a/src/app_core/document_layer.h +++ b/src/app_core/document_layer.h @@ -32,6 +32,21 @@ enum class DocumentLayerOperation { set_highlight, }; +enum class DocumentLayerMenuCommand { + clear, + rename, + merge_down, +}; + +enum class DocumentLayerMenuAction { + clear_current_layer, + show_rename_dialog, + merge_with_lower_layer, + show_merge_animated_not_supported, + no_op_select_layer, + no_op_select_upper_layer, +}; + struct DocumentLayerRenamePlan { std::string old_name; std::string new_name; @@ -55,6 +70,14 @@ struct DocumentLayerOperationPlan { bool updates_title = false; }; +struct DocumentLayerMenuPlan { + DocumentLayerMenuCommand command = DocumentLayerMenuCommand::clear; + DocumentLayerMenuAction action = DocumentLayerMenuAction::clear_current_layer; + std::string label; + int from_index = 0; + int to_index = 0; +}; + [[nodiscard]] inline pp::foundation::Status validate_layer_index( int layer_count, int index) noexcept @@ -328,4 +351,63 @@ struct DocumentLayerOperationPlan { return pp::foundation::Result::success(plan); } +[[nodiscard]] inline pp::foundation::Result plan_document_layer_menu( + DocumentLayerMenuCommand command, + bool has_current_layer, + int current_index, + int animation_duration, + std::string_view current_layer_name, + std::string_view lower_layer_name) +{ + if (current_index < 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("current layer index must not be negative")); + } + if (animation_duration < 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("animation duration must not be negative")); + } + + DocumentLayerMenuPlan plan; + plan.command = command; + plan.from_index = current_index; + plan.to_index = current_index > 0 ? current_index - 1 : 0; + + switch (command) { + case DocumentLayerMenuCommand::clear: + plan.action = has_current_layer + ? DocumentLayerMenuAction::clear_current_layer + : DocumentLayerMenuAction::no_op_select_layer; + plan.label = has_current_layer + ? "Clear Layer " + std::string(current_layer_name) + : "Clear Layer (Select a layer)"; + break; + case DocumentLayerMenuCommand::rename: + plan.action = has_current_layer + ? DocumentLayerMenuAction::show_rename_dialog + : DocumentLayerMenuAction::no_op_select_layer; + plan.label = has_current_layer + ? "Rename Layer " + std::string(current_layer_name) + : "Rename Layer (Select a layer)"; + break; + case DocumentLayerMenuCommand::merge_down: + if (!has_current_layer) { + plan.action = DocumentLayerMenuAction::no_op_select_layer; + plan.label = "Merge Layer (Select a layer)"; + } else if (animation_duration > 1) { + plan.action = DocumentLayerMenuAction::show_merge_animated_not_supported; + plan.label = "Merge Layer (Animation not supported)"; + } else if (current_index <= 0) { + plan.action = DocumentLayerMenuAction::no_op_select_upper_layer; + plan.label = "Merge Layer (Select upper layers)"; + } else { + plan.action = DocumentLayerMenuAction::merge_with_lower_layer; + plan.label = "Merge with " + std::string(lower_layer_name); + } + break; + } + + return pp::foundation::Result::success(std::move(plan)); +} + } diff --git a/src/app_layout.cpp b/src/app_layout.cpp index df46800..0c21baf 100644 --- a/src/app_layout.cpp +++ b/src/app_layout.cpp @@ -190,6 +190,39 @@ void apply_file_menu_plan(App& app, pp::app::FileMenuCommand command) } } +pp::app::DocumentLayerMenuPlan make_layer_menu_plan( + pp::app::DocumentLayerMenuCommand command, + App& app) +{ + const bool has_current_layer = app.layers && app.layers->m_current_layer; + const int current_index = app.canvas && app.canvas->m_canvas + ? app.canvas->m_canvas->m_current_layer_idx + : 0; + const int animation_duration = Canvas::I + ? Canvas::I->anim_duration() + : 0; + const std::string current_name = has_current_layer + ? app.layers->m_current_layer->m_label_text + : std::string {}; + std::string lower_name; + if (app.canvas && app.canvas->m_canvas && current_index > 0 + && current_index - 1 < static_cast(app.canvas->m_canvas->m_layers.size())) + { + lower_name = app.canvas->m_canvas->m_layers[current_index - 1]->m_name; + } + + const auto plan = pp::app::plan_document_layer_menu( + command, + has_current_layer, + current_index, + animation_duration, + current_name, + lower_name); + if (plan) + return plan.value(); + return {}; +} + } // namespace void App::title_update() @@ -1513,67 +1546,52 @@ void App::init_menu_layer() layout[main_id]->add_child(popup); popup->find("layer-clear")->on_click = [this, popup](Node*) { - canvas->m_canvas->clear(); + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::clear, *this); + if (plan.action == pp::app::DocumentLayerMenuAction::clear_current_layer) + canvas->m_canvas->clear(); popup->mouse_release(); popup->destroy(); }; - if (layers->m_current_layer) + { + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::clear, *this); popup->find("layer-clear")-> find("menu-label")-> - set_text(("Clear Layer " + layers->m_current_layer->m_label_text).c_str()); + set_text(plan.label.c_str()); + } popup->find("layer-rename")->on_click = [this, popup](Node*) { - dialog_layer_rename(); + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::rename, *this); + if (plan.action == pp::app::DocumentLayerMenuAction::show_rename_dialog) + dialog_layer_rename(); popup->mouse_release(); popup->destroy(); }; - if (layers->m_current_layer) + { + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::rename, *this); popup->find("layer-rename")-> find("menu-label")-> - set_text(("Rename Layer " + layers->m_current_layer->m_label_text).c_str()); - else - popup->find("layer-rename")-> - find("menu-label")-> - set_text("Rename Layer (Select a layer)"); + set_text(plan.label.c_str()); + } popup->find("layer-merge")->on_click = [this, popup](Node*) { - //layers->get_child_index(layers->) - if (Canvas::I->anim_duration() > 1) + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::merge_down, *this); + if (plan.action == pp::app::DocumentLayerMenuAction::show_merge_animated_not_supported) { message_box("Not supported", "Merging animated layers is not supported yet."); } - else + else if (plan.action == pp::app::DocumentLayerMenuAction::merge_with_lower_layer) { - int current_idx_order = Canvas::I->m_current_layer_idx; - if (current_idx_order > 0) - { - layers->merge(current_idx_order, current_idx_order - 1, true); - } + layers->merge(plan.from_index, plan.to_index, true); } popup->mouse_release(); popup->destroy(); }; - if (layers->m_current_layer) { - int current_idx_order = canvas->m_canvas->m_current_layer_idx; - if (current_idx_order > 0) - { - int down_layer_idx = current_idx_order - 1; - popup->find("layer-merge")-> - find("menu-label")-> - set_text(("Merge with " + canvas->m_canvas->m_layers[down_layer_idx]->m_name).c_str()); - } - else - { - popup->find("layer-merge")-> - find("menu-label")-> - set_text("Merge Layer (Select upper layers)"); - } - } - else + const auto plan = make_layer_menu_plan(pp::app::DocumentLayerMenuCommand::merge_down, *this); popup->find("layer-merge")-> - find("menu-label")-> - set_text("Merge Layer (Select a layer)"); + find("menu-label")-> + set_text(plan.label.c_str()); + } }; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c8c77a3..ab8c58e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -898,6 +898,30 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_layer_menu_merge_smoke + COMMAND pano_cli plan-layer-menu --command merge --current-index 2 --current-name Ink --lower-name Paint) + set_tests_properties(pano_cli_plan_layer_menu_merge_smoke PROPERTIES + LABELS "app;document;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-menu\".*\"command\":\"merge\".*\"action\":\"merge-with-lower-layer\".*\"label\":\"Merge with Paint\".*\"fromIndex\":2.*\"toIndex\":1") + + add_test(NAME pano_cli_plan_layer_menu_merge_animated_blocked_smoke + COMMAND pano_cli plan-layer-menu --command merge --current-index 2 --current-name Ink --lower-name Paint --animation-duration 3) + set_tests_properties(pano_cli_plan_layer_menu_merge_animated_blocked_smoke PROPERTIES + LABELS "app;document;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-menu\".*\"animationDuration\":3.*\"action\":\"show-merge-animated-not-supported\"") + + add_test(NAME pano_cli_plan_layer_menu_missing_selection_smoke + COMMAND pano_cli plan-layer-menu --command rename --no-current-layer) + set_tests_properties(pano_cli_plan_layer_menu_missing_selection_smoke PROPERTIES + LABELS "app;document;integration;desktop-fast;fuzz" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-menu\".*\"hasCurrentLayer\":false.*\"action\":\"no-op-select-layer\".*\"label\":\"Rename Layer \\(Select a layer\\)\"") + + add_test(NAME pano_cli_plan_layer_menu_rejects_bad_state + COMMAND pano_cli plan-layer-menu --command merge --current-index -1) + set_tests_properties(pano_cli_plan_layer_menu_rejects_bad_state PROPERTIES + LABELS "app;document;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_layer_operation_add_smoke COMMAND pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint) set_tests_properties(pano_cli_plan_layer_operation_add_smoke PROPERTIES diff --git a/tests/app_core/document_layer_tests.cpp b/tests/app_core/document_layer_tests.cpp index d0ce969..372b86f 100644 --- a/tests/app_core/document_layer_tests.cpp +++ b/tests/app_core/document_layer_tests.cpp @@ -178,6 +178,115 @@ void layer_highlight_is_transient(pp::tests::Harness& harness) PP_EXPECT(harness, !pp::app::plan_document_layer_highlight(2, 2, true)); } +void layer_menu_labels_selected_layer_commands(pp::tests::Harness& harness) +{ + const auto clear = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::clear, + true, + 1, + 1, + "Paint", + "Base"); + const auto rename = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::rename, + true, + 1, + 1, + "Paint", + "Base"); + + PP_EXPECT(harness, clear); + PP_EXPECT(harness, rename); + if (clear) { + PP_EXPECT(harness, clear.value().label == "Clear Layer Paint"); + PP_EXPECT(harness, clear.value().action == pp::app::DocumentLayerMenuAction::clear_current_layer); + } + if (rename) { + PP_EXPECT(harness, rename.value().label == "Rename Layer Paint"); + PP_EXPECT(harness, rename.value().action == pp::app::DocumentLayerMenuAction::show_rename_dialog); + } +} + +void layer_menu_plans_merge_down_or_blocks_it(pp::tests::Harness& harness) +{ + const auto merge = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::merge_down, + true, + 2, + 1, + "Ink", + "Paint"); + const auto base_layer = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::merge_down, + true, + 0, + 1, + "Base", + ""); + const auto animated = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::merge_down, + true, + 2, + 3, + "Ink", + "Paint"); + + PP_EXPECT(harness, merge); + PP_EXPECT(harness, base_layer); + PP_EXPECT(harness, animated); + if (merge) { + PP_EXPECT(harness, merge.value().label == "Merge with Paint"); + PP_EXPECT(harness, merge.value().from_index == 2); + PP_EXPECT(harness, merge.value().to_index == 1); + PP_EXPECT(harness, merge.value().action == pp::app::DocumentLayerMenuAction::merge_with_lower_layer); + } + if (base_layer) { + PP_EXPECT(harness, base_layer.value().action == pp::app::DocumentLayerMenuAction::no_op_select_upper_layer); + PP_EXPECT(harness, base_layer.value().label == "Merge Layer (Select upper layers)"); + } + if (animated) { + PP_EXPECT( + harness, + animated.value().action == pp::app::DocumentLayerMenuAction::show_merge_animated_not_supported); + } +} + +void layer_menu_handles_missing_selection_and_bad_state(pp::tests::Harness& harness) +{ + const auto missing = pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::rename, + false, + 0, + 1, + "", + ""); + + PP_EXPECT(harness, missing); + if (missing) { + PP_EXPECT(harness, missing.value().action == pp::app::DocumentLayerMenuAction::no_op_select_layer); + PP_EXPECT(harness, missing.value().label == "Rename Layer (Select a layer)"); + } + + PP_EXPECT( + harness, + !pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::merge_down, + true, + -1, + 1, + "Ink", + "Paint")); + PP_EXPECT( + harness, + !pp::app::plan_document_layer_menu( + pp::app::DocumentLayerMenuCommand::merge_down, + true, + 1, + -1, + "Ink", + "Paint")); +} + } int main() @@ -192,5 +301,8 @@ int main() harness.run("layer remove keeps at least one layer", layer_remove_keeps_at_least_one_layer); harness.run("layer metadata plans validate values", layer_metadata_plans_validate_values); harness.run("layer highlight is transient", layer_highlight_is_transient); + harness.run("layer menu labels selected layer commands", layer_menu_labels_selected_layer_commands); + harness.run("layer menu plans merge down or blocks it", layer_menu_plans_merge_down_or_blocks_it); + harness.run("layer menu handles missing selection and bad state", layer_menu_handles_missing_selection_and_bad_state); return harness.finish(); } diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 101e87d..274927d 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -270,6 +270,15 @@ struct PlanLayerOperationArgs { int blend_mode = 0; }; +struct PlanLayerMenuArgs { + std::string command = "merge"; + bool has_current_layer = true; + int current_index = 0; + int animation_duration = 1; + std::string current_name = "Layer"; + std::string lower_name = "Layer 0"; +}; + struct PlanAnimationOperationArgs { std::string kind = "goto"; int frame_count = 1; @@ -681,6 +690,60 @@ const char* document_layer_operation_name(pp::app::DocumentLayerOperation operat return "select"; } +const char* document_layer_menu_command_name(pp::app::DocumentLayerMenuCommand command) noexcept +{ + switch (command) { + case pp::app::DocumentLayerMenuCommand::clear: + return "clear"; + case pp::app::DocumentLayerMenuCommand::rename: + return "rename"; + case pp::app::DocumentLayerMenuCommand::merge_down: + return "merge"; + } + + return "clear"; +} + +const char* document_layer_menu_action_name(pp::app::DocumentLayerMenuAction action) noexcept +{ + switch (action) { + case pp::app::DocumentLayerMenuAction::clear_current_layer: + return "clear-current-layer"; + case pp::app::DocumentLayerMenuAction::show_rename_dialog: + return "show-rename-dialog"; + case pp::app::DocumentLayerMenuAction::merge_with_lower_layer: + return "merge-with-lower-layer"; + case pp::app::DocumentLayerMenuAction::show_merge_animated_not_supported: + return "show-merge-animated-not-supported"; + case pp::app::DocumentLayerMenuAction::no_op_select_layer: + return "no-op-select-layer"; + case pp::app::DocumentLayerMenuAction::no_op_select_upper_layer: + return "no-op-select-upper-layer"; + } + + return "no-op-select-layer"; +} + +pp::foundation::Result parse_document_layer_menu_command( + std::string_view command) +{ + if (command == "clear") { + return pp::foundation::Result::success( + pp::app::DocumentLayerMenuCommand::clear); + } + if (command == "rename") { + return pp::foundation::Result::success( + pp::app::DocumentLayerMenuCommand::rename); + } + if (command == "merge" || command == "merge-down") { + return pp::foundation::Result::success( + pp::app::DocumentLayerMenuCommand::merge_down); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown layer menu command")); +} + const char* document_animation_operation_name(pp::app::DocumentAnimationOperation operation) noexcept { switch (operation) { @@ -1205,6 +1268,7 @@ void print_help() << " plan-image-import --width N --height N\n" << " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n" << " plan-layer-rename --old-name NAME --new-name NAME\n" + << " plan-layer-menu --command clear|rename|merge [--no-current-layer] [--current-index N] [--animation-duration N] [--current-name NAME] [--lower-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" @@ -3085,6 +3149,91 @@ int plan_layer_rename(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_layer_menu_args( + int argc, + char** argv, + PlanLayerMenuArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--command" || key == "--current-name" || key == "--lower-name") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + if (key == "--command") { + args.command = argv[++i]; + } else if (key == "--current-name") { + args.current_name = argv[++i]; + } else { + args.lower_name = argv[++i]; + } + } else if (key == "--current-index" || key == "--animation-duration") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_i32_arg(argv[++i]); + if (!value) { + return value.status(); + } + if (key == "--current-index") { + args.current_index = value.value(); + } else { + args.animation_duration = value.value(); + } + } else if (key == "--no-current-layer") { + args.has_current_layer = false; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int plan_layer_menu(int argc, char** argv) +{ + PlanLayerMenuArgs args; + const auto status = parse_plan_layer_menu_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-layer-menu", status.message); + return 2; + } + + const auto command = parse_document_layer_menu_command(args.command); + if (!command) { + print_error("plan-layer-menu", command.status().message); + return 2; + } + + const auto plan = pp::app::plan_document_layer_menu( + command.value(), + args.has_current_layer, + args.current_index, + args.animation_duration, + args.current_name, + args.lower_name); + if (!plan) { + print_error("plan-layer-menu", plan.status().message); + return 2; + } + + const auto& value = plan.value(); + std::cout << "{\"ok\":true,\"command\":\"plan-layer-menu\"" + << ",\"state\":{\"command\":\"" << json_escape(args.command) + << "\",\"hasCurrentLayer\":" << json_bool(args.has_current_layer) + << ",\"currentIndex\":" << args.current_index + << ",\"animationDuration\":" << args.animation_duration + << ",\"currentName\":\"" << json_escape(args.current_name) + << "\",\"lowerName\":\"" << json_escape(args.lower_name) + << "\"},\"plan\":{\"command\":\"" << document_layer_menu_command_name(value.command) + << "\",\"action\":\"" << document_layer_menu_action_name(value.action) + << "\",\"label\":\"" << json_escape(value.label) + << "\",\"fromIndex\":" << value.from_index + << ",\"toIndex\":" << value.to_index + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_layer_operation_args( int argc, char** argv, @@ -6446,6 +6595,10 @@ int main(int argc, char** argv) return plan_layer_rename(argc, argv); } + if (command == "plan-layer-menu") { + return plan_layer_menu(argc, argv); + } + if (command == "plan-layer-operation") { return plan_layer_operation(argc, argv); }