From ea96f388752bc71c3e064d0c587743e970fb3e9d Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 3 Jun 2026 12:52:46 +0200 Subject: [PATCH] Add tools menu service boundary --- docs/modernization/debt.md | 2 +- docs/modernization/roadmap.md | 15 ++++--- src/app_core/tools_menu.h | 44 +++++++++++++++++++ src/app_layout.cpp | 66 +++++++++++++++++++++++----- tests/app_core/tools_menu_tests.cpp | 68 +++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 19 deletions(-) diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 6671cac..8ff305e 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -50,7 +50,7 @@ agent or engineer to remove them without reconstructing context from chat. | 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 | -| DEBT-0033 | Open | Modernization | Tools menu and floating-panel planning now consumes pure `pp_app_core` through `App::init_menu_tools`, `pano_cli plan-tools-menu`, and `pano_cli plan-tools-panel`, but live execution still constructs legacy `NodePanelFloating` panels, mutates legacy panel nodes, clears `CanvasModeGrid`, resets `NodeCanvas` camera state, opens legacy shortcuts UI, and calls the iOS SonarPen bridge directly | Preserve current Tools menu behavior while UI shell actions move toward app/UI/platform services | `pp_app_core_tools_menu_tests`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-tools-panel --panel animation --already-visible`; `ctest --preset desktop-fast --build-config Debug` | Tools panel creation, submenu routing, grid clear, camera reset, shortcuts dialog, and SonarPen dispatch are owned by app/UI/platform services with `App::init_menu_tools` acting only as a UI adapter | +| DEBT-0033 | Open | Modernization | Tools menu planning and direct command execution dispatch now consume pure `pp_app_core` through `App::init_menu_tools`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, and the `ToolsMenuServices` boundary, but live adapters still construct legacy `NodePanelFloating` panels, mutate legacy panel nodes, clear `CanvasModeGrid`, reset `NodeCanvas` camera state, open legacy shortcuts UI, and call the iOS SonarPen bridge directly | Preserve current Tools menu behavior while UI shell actions move toward app/UI/platform services | `pp_app_core_tools_menu_tests`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-tools-panel --panel animation --already-visible`; `ctest --preset desktop-fast --build-config Debug` | Tools panel creation, submenu routing, grid clear, camera reset, shortcuts dialog, and SonarPen dispatch are owned by injected app/UI/platform services with `App::init_menu_tools` acting only as a UI adapter and no legacy Tools adapter | | DEBT-0034 | Open | Modernization | About menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_about`, `pano_cli plan-about-menu`, and the `AboutMenuServices` boundary, but the live adapter still opens legacy About/manual/what's-new dialogs, invokes the injected crash hook, and runs the legacy Canvas stroke performance test directly | Preserve About menu behavior while dialogs and diagnostics move toward app/UI/platform services | `pp_app_core_about_menu_tests`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-about-menu --command performance --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | About/manual/what's-new dialog dispatch, crash-test dispatch, and performance-test execution are owned by injected app/UI/platform services with `App::init_menu_about` acting only as a UI adapter and no legacy About adapter | | DEBT-0035 | Open | Modernization | Main toolbar/status command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `pano_cli plan-main-toolbar`, and the `MainToolbarServices` boundary, but the live adapter still opens legacy open/save/settings/message-box dialogs, mutates legacy `ActionManager` history, and clears the legacy `Canvas` directly | Preserve reachable toolbar/status behavior while app shell commands move toward app/document/UI services | `pp_app_core_main_toolbar_tests`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-main-toolbar --command clear-canvas --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Open/save/settings/message-box routing, undo/redo/clear-history execution, and canvas-clear execution are owned by injected app/document/UI services with `App::init_toolbar_main` acting only as a UI adapter and no legacy toolbar adapter | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 7f3431f..eee9b4d 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -548,10 +548,11 @@ stroke preview, and preset popup execution continue. `pano_cli plan-tools-menu` and `pano_cli plan-tools-panel` expose app-core planning for top-level Tools commands and floating-panel requests, including already-visible no-ops, panel chrome metadata, shortcuts, camera reset, -grid-clear, and platform-only SonarPen gating before legacy UI/panel/canvas -execution continues. The live animation panel route now also checks animation -panel visibility and applies animation panel layout state instead of using the -grid panel by mistake. +grid-clear, and platform-only SonarPen gating. Direct Tools commands now +dispatch through `ToolsMenuServices` before the legacy UI/panel/canvas/platform +adapters continue execution. The live animation panel route now also checks +animation panel visibility and applies animation panel layout state instead of +using the grid panel by mistake. `pano_cli plan-about-menu` exposes app-core planning for About menu help, about, what's-new, crash-test, and performance-test commands, including versioned what's-new labels, diagnostic gating, and no-canvas performance-test @@ -1277,9 +1278,9 @@ Results: `pano_cli_plan_quick_operation_rejects_bad_restore` passed and expose live quick-panel planning as JSON automation. - `pp_app_core_tools_menu_tests` passed, covering Tools submenu routing, - root-closing commands, platform-only SonarPen gating, floating panel chrome - metadata, already-visible panel no-ops, and animation panel non-droppable - state. + root-closing commands, platform-only SonarPen gating, executor dispatch, + unavailable no-op actions, floating panel chrome metadata, already-visible + panel no-ops, and animation panel non-droppable state. - `pano_cli_plan_tools_menu_shortcuts_smoke`, `pano_cli_plan_tools_menu_sonarpen_unavailable_smoke`, `pano_cli_plan_tools_panel_layers_smoke`, diff --git a/src/app_core/tools_menu.h b/src/app_core/tools_menu.h index 0bc8dd9..d4d43e9 100644 --- a/src/app_core/tools_menu.h +++ b/src/app_core/tools_menu.h @@ -1,5 +1,7 @@ #pragma once +#include "foundation/result.h" + #include namespace pp::app { @@ -57,6 +59,18 @@ struct ToolsPanelPlan { bool hides_embedded_title = false; }; +class ToolsMenuServices { +public: + virtual ~ToolsMenuServices() = default; + + virtual void show_panels_submenu() = 0; + virtual void show_options_submenu() = 0; + virtual void clear_grid_overlays() = 0; + virtual void reset_camera() = 0; + virtual void show_shortcuts_dialog() = 0; + virtual void start_sonarpen() = 0; +}; + [[nodiscard]] constexpr ToolsMenuPlan plan_tools_menu_command( ToolsMenuCommand command, bool sonarpen_available = false) noexcept @@ -138,4 +152,34 @@ struct ToolsPanelPlan { return plan; } +[[nodiscard]] inline pp::foundation::Status execute_tools_menu_plan( + const ToolsMenuPlan& plan, + ToolsMenuServices& services) +{ + switch (plan.action) { + case ToolsMenuAction::show_panels_submenu: + services.show_panels_submenu(); + return pp::foundation::Status::success(); + case ToolsMenuAction::show_options_submenu: + services.show_options_submenu(); + return pp::foundation::Status::success(); + case ToolsMenuAction::clear_grid_overlays: + services.clear_grid_overlays(); + return pp::foundation::Status::success(); + case ToolsMenuAction::reset_camera: + services.reset_camera(); + return pp::foundation::Status::success(); + case ToolsMenuAction::show_shortcuts_dialog: + services.show_shortcuts_dialog(); + return pp::foundation::Status::success(); + case ToolsMenuAction::start_sonarpen: + services.start_sonarpen(); + return pp::foundation::Status::success(); + case ToolsMenuAction::no_op_unavailable: + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown tools menu action"); +} + } diff --git a/src/app_layout.cpp b/src/app_layout.cpp index e703cad..d5a0c30 100644 --- a/src/app_layout.cpp +++ b/src/app_layout.cpp @@ -376,6 +376,49 @@ private: App& app_; }; +class LegacyToolsMenuServices final : public pp::app::ToolsMenuServices { +public: + explicit LegacyToolsMenuServices(App& app) noexcept + : app_(app) + { + } + + void show_panels_submenu() override + { + } + + void show_options_submenu() override + { + } + + void clear_grid_overlays() override + { + auto* mode = static_cast(Canvas::modes[(int)kCanvasMode::Grid][0]); + mode->clear(); + } + + void reset_camera() override + { + if (app_.canvas) + app_.canvas->reset_camera(); + } + + void show_shortcuts_dialog() override + { + app_.dialog_shortcuts(); + } + + void start_sonarpen() override + { +#if __IOS__ + [app_.ios_app sonarpen_start]; +#endif + } + +private: + App& app_; +}; + void execute_main_toolbar_plan(App& app, const pp::app::MainToolbarPlan& plan) { LegacyMainToolbarServices services(app); @@ -392,6 +435,14 @@ void execute_about_menu_plan(App& app, const pp::app::AboutMenuPlan& plan) LOG("About menu action failed: %s", status.message); } +void execute_tools_menu_plan(App& app, const pp::app::ToolsMenuPlan& plan) +{ + LegacyToolsMenuServices services(app); + const auto status = pp::app::execute_tools_menu_plan(plan, services); + if (!status.ok()) + LOG("Tools menu action failed: %s", status.message); +} + } // namespace void App::title_update() @@ -1580,11 +1631,7 @@ void App::init_menu_tools() popup_exp->find("clear-grids")->on_click = [this, popup_exp](Node*) { const auto plan = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::clear_grids); - if (plan.action == pp::app::ToolsMenuAction::clear_grid_overlays) - { - CanvasModeGrid* mode = (CanvasModeGrid*)Canvas::modes[(int)kCanvasMode::Grid][0]; - mode->clear(); - } + execute_tools_menu_plan(*this, plan); if (plan.closes_root_popup) { popup_exp->mouse_release(); @@ -1594,8 +1641,7 @@ void App::init_menu_tools() popup_exp->find("camera-reset")->on_click = [this, popup_exp](Node*) { const auto plan = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::reset_camera); - if (plan.action == pp::app::ToolsMenuAction::reset_camera) - canvas->reset_camera(); + execute_tools_menu_plan(*this, plan); if (plan.closes_root_popup) { popup_exp->mouse_release(); @@ -1605,8 +1651,7 @@ void App::init_menu_tools() popup_exp->find("shortcuts")->on_click = [this, popup_exp](Node*) { const auto plan = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::shortcuts); - if (plan.action == pp::app::ToolsMenuAction::show_shortcuts_dialog) - dialog_shortcuts(); + execute_tools_menu_plan(*this, plan); if (plan.closes_root_popup) { popup_exp->mouse_release(); @@ -1625,8 +1670,7 @@ void App::init_menu_tools() #if __IOS__ popup_exp->find("sonarpen")->on_click = [this, popup_exp](Node*) { const auto plan = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::sonarpen, true); - if (plan.action == pp::app::ToolsMenuAction::start_sonarpen) - [ios_app sonarpen_start]; + execute_tools_menu_plan(*this, plan); if (plan.closes_root_popup) { popup_exp->mouse_release(); diff --git a/tests/app_core/tools_menu_tests.cpp b/tests/app_core/tools_menu_tests.cpp index 651e38d..559dd95 100644 --- a/tests/app_core/tools_menu_tests.cpp +++ b/tests/app_core/tools_menu_tests.cpp @@ -3,6 +3,33 @@ namespace { +class FakeToolsMenuServices final : public pp::app::ToolsMenuServices { +public: + void show_panels_submenu() override { panels_submenus += 1; } + void show_options_submenu() override { options_submenus += 1; } + void clear_grid_overlays() override { clear_grid_calls += 1; } + void reset_camera() override { reset_camera_calls += 1; } + void show_shortcuts_dialog() override { shortcut_dialogs += 1; } + void start_sonarpen() override { sonarpen_starts += 1; } + + [[nodiscard]] int total_calls() const noexcept + { + return panels_submenus + + options_submenus + + clear_grid_calls + + reset_camera_calls + + shortcut_dialogs + + sonarpen_starts; + } + + int panels_submenus = 0; + int options_submenus = 0; + int clear_grid_calls = 0; + int reset_camera_calls = 0; + int shortcut_dialogs = 0; + int sonarpen_starts = 0; +}; + void tools_menu_maps_submenus_and_commands(pp::tests::Harness& harness) { const auto panels = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::panels); @@ -67,6 +94,45 @@ void tools_panel_no_ops_when_panel_is_already_visible(pp::tests::Harness& harnes PP_EXPECT(harness, visible.hides_embedded_title == hidden.hides_embedded_title); } +void tools_menu_executor_dispatches_direct_commands(pp::tests::Harness& harness) +{ + FakeToolsMenuServices services; + + const auto panels = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::panels); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(panels, services).ok()); + PP_EXPECT(harness, services.panels_submenus == 1); + + const auto options = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::options); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(options, services).ok()); + PP_EXPECT(harness, services.options_submenus == 1); + + const auto clear = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::clear_grids); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(clear, services).ok()); + PP_EXPECT(harness, services.clear_grid_calls == 1); + + const auto camera = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::reset_camera); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(camera, services).ok()); + PP_EXPECT(harness, services.reset_camera_calls == 1); + + const auto shortcuts = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::shortcuts); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(shortcuts, services).ok()); + PP_EXPECT(harness, services.shortcut_dialogs == 1); + + const auto sonarpen = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::sonarpen, true); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(sonarpen, services).ok()); + PP_EXPECT(harness, services.sonarpen_starts == 1); +} + +void tools_menu_executor_preserves_unavailable_no_op(pp::tests::Harness& harness) +{ + FakeToolsMenuServices services; + + const auto unavailable = pp::app::plan_tools_menu_command(pp::app::ToolsMenuCommand::sonarpen, false); + PP_EXPECT(harness, unavailable.action == pp::app::ToolsMenuAction::no_op_unavailable); + PP_EXPECT(harness, pp::app::execute_tools_menu_plan(unavailable, services).ok()); + PP_EXPECT(harness, services.total_calls() == 0); +} + } int main() @@ -76,5 +142,7 @@ int main() harness.run("tools menu gates platform only actions", tools_menu_gates_platform_only_actions); harness.run("tools panel plans floating panel metadata", tools_panel_plans_floating_panel_metadata); harness.run("tools panel no ops when panel is already visible", tools_panel_no_ops_when_panel_is_already_visible); + harness.run("tools menu executor dispatches direct commands", tools_menu_executor_dispatches_direct_commands); + harness.run("tools menu executor preserves unavailable no op", tools_menu_executor_preserves_unavailable_no_op); return harness.finish(); }