From 9c3f56954eab8b385af71c7e6b6c573f5687dc74 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 3 Jun 2026 13:04:00 +0200 Subject: [PATCH] Add export menu service boundary --- docs/modernization/debt.md | 2 +- docs/modernization/roadmap.md | 8 +- src/app_core/document_export.h | 54 ++++++++++ src/app_layout.cpp | 64 +++++------ tests/app_core/document_export_tests.cpp | 132 +++++++++++++++++++++++ 5 files changed, 221 insertions(+), 39 deletions(-) diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 241cac7..d6c4406 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -47,7 +47,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0027 | Open | Modernization | Canvas draw-tool toolbar command, canvas input mode switching, and active-state planning now consume pure `pp_app_core` through `App::init_toolbar_draw`, `App::update`, `NodeCanvas`, `pano_cli plan-canvas-tool`, and `pano_cli plan-canvas-tool-state`, but live execution/state storage still mutates or reads legacy `Canvas` mode state, pen picking state, touch-lock state, and transform copy/cut action objects directly | Preserve current toolbar, stylus eraser, and keyboard draw/erase behavior while canvas input/tools move toward an app/document command boundary | `pp_app_core_canvas_tool_ui_tests`; `pano_cli plan-canvas-tool --kind copy`; `pano_cli plan-canvas-tool-state --mode draw --picking --touch-lock`; `ctest --preset desktop-fast --build-config Debug` | Canvas tool selection, toolbar state refresh, picking, touch lock, stylus eraser/key mode switching, and transform action execution are owned by app/document/canvas services with toolbar/canvas callbacks acting only as adapters | | DEBT-0028 | Open | Modernization | Canvas clear command planning now consumes pure `pp_app_core` through `App::init_toolbar_main` and `pano_cli plan-canvas-clear`, but live execution still calls legacy `Canvas::clear`, which records `ActionLayerClear`, clears the current layer/frame, and marks legacy `Canvas::I` unsaved directly | Preserve clear-current-layer behavior while canvas/document commands move toward document/app command services | `pp_app_core_document_canvas_tests`; `pano_cli plan-canvas-clear --r 0 --g 0.1 --b 0.2 --a 0.3`; `pano_cli plan-canvas-clear --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Canvas clear execution, undo recording, dirty-state updates, and clear color handling are owned by document/app services with toolbar callbacks acting only as adapters | | 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-0030 | Open | Modernization | File export menu action planning and execution dispatch now consume pure `pp_app_core` through the File menu, `pano_cli plan-export-menu`, and the `DocumentExportMenuServices` boundary, but the live adapter 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 injected document/app/renderer/video services with File-menu callbacks acting only as UI adapters and no legacy export adapter | | DEBT-0031 | Open | Modernization | Top-level File menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_file`, `pano_cli plan-file-menu`, and the `FileMenuServices` boundary, but the live adapter 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 injected app/document/platform services with `App::init_menu_file` acting only as a UI adapter and no legacy File menu adapter | | DEBT-0032 | Open | Modernization | Layer menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_layer`, `pano_cli plan-layer-menu`, and the `DocumentLayerMenuServices` boundary, but the live adapter 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 injected document/app services with Layer-menu callbacks acting only as UI adapters and no legacy Layer menu 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 | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 651ae46..19cdeb2 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -527,8 +527,9 @@ commands now dispatch through `FileMenuServices` before legacy dialogs, pickers, platform services, cloud code, and canvas workflows continue. `pano_cli plan-export-menu` exposes app-core planning for File menu export choices, including image, layer, cube-face, depth, animation-frame, MP4, and -timelapse dialog routing plus license/canvas gating before legacy export dialogs -and renderer/video execution continue. +timelapse dialog routing plus license/canvas gating. Export menu commands now +dispatch through `DocumentExportMenuServices` before legacy export dialogs and +renderer/video execution continue. `pano_cli plan-grid-operation` exposes app-core planning for grid heightmap pick/load/reload/clear, lightmap render capability/limit checks, and heightmap commit used by the live grid panel before legacy image loading, OpenGL texture @@ -1256,7 +1257,8 @@ Results: menu routing as JSON automation. - `pp_app_core_document_export_tests` passed, now also covering export menu dialog routing, demo-mode MP4/timelapse license gating, and missing-canvas - handling before legacy export dialogs continue. + handling, plus export menu executor dispatch for all dialog, blocked, and + unavailable actions before legacy export dialogs continue. - `pano_cli_plan_export_menu_png_smoke`, `pano_cli_plan_export_menu_mp4_demo_blocked_smoke`, `pano_cli_plan_export_menu_no_canvas_smoke`, and diff --git a/src/app_core/document_export.h b/src/app_core/document_export.h index a13b0c3..e0b9dc1 100644 --- a/src/app_core/document_export.h +++ b/src/app_core/document_export.h @@ -62,6 +62,21 @@ struct DocumentExportMenuPlan { bool opens_dialog = true; }; +class DocumentExportMenuServices { +public: + virtual ~DocumentExportMenuServices() = default; + + virtual void show_jpeg_dialog() = 0; + virtual void show_png_dialog() = 0; + virtual void show_layers_dialog() = 0; + virtual void show_cube_faces_dialog() = 0; + virtual void show_depth_dialog() = 0; + virtual void show_animation_frames_dialog() = 0; + virtual void show_animation_mp4_dialog() = 0; + virtual void show_timelapse_dialog() = 0; + virtual void show_license_disabled() = 0; +}; + [[nodiscard]] constexpr DocumentExportStartDecision plan_document_export_start( bool requires_license, bool license_valid, @@ -226,4 +241,43 @@ struct DocumentExportMenuPlan { return pp::foundation::Result::success(std::move(target)); } +[[nodiscard]] inline pp::foundation::Status execute_document_export_menu_plan( + const DocumentExportMenuPlan& plan, + DocumentExportMenuServices& services) +{ + switch (plan.action) { + case DocumentExportMenuAction::show_jpeg_dialog: + services.show_jpeg_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_png_dialog: + services.show_png_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_layers_dialog: + services.show_layers_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_cube_faces_dialog: + services.show_cube_faces_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_depth_dialog: + services.show_depth_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_animation_frames_dialog: + services.show_animation_frames_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_animation_mp4_dialog: + services.show_animation_mp4_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_timelapse_dialog: + services.show_timelapse_dialog(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::show_license_disabled: + services.show_license_disabled(); + return pp::foundation::Status::success(); + case DocumentExportMenuAction::unavailable_no_canvas: + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown document export menu action"); +} + } diff --git a/src/app_layout.cpp b/src/app_layout.cpp index 2b84147..438f39a 100644 --- a/src/app_layout.cpp +++ b/src/app_layout.cpp @@ -85,46 +85,40 @@ bool apply_brush_preset_plan(App& app, const std::shared_ptr& brush) bool apply_document_export_menu_plan(App& app, pp::app::DocumentExportMenuKind kind) { + class LegacyDocumentExportMenuServices final : public pp::app::DocumentExportMenuServices { + public: + explicit LegacyDocumentExportMenuServices(App& app) noexcept + : app_(app) + { + } + + void show_jpeg_dialog() override { app_.dialog_export(".jpg"); } + void show_png_dialog() override { app_.dialog_export(".png"); } + void show_layers_dialog() override { app_.dialog_export_layers(); } + void show_cube_faces_dialog() override { app_.dialog_export_cube_faces(); } + void show_depth_dialog() override { app_.dialog_export_depth(); } + void show_animation_frames_dialog() override { app_.dialog_export_anim_frames(); } + void show_animation_mp4_dialog() override { app_.dialog_export_mp4(); } + void show_timelapse_dialog() override { app_.dialog_timelapse_export(); } + void show_license_disabled() override + { + app_.message_box("License", "This function is disabled in demo mode."); + } + + private: + App& app_; + }; + const auto requires_license = pp::app::document_export_menu_requires_license(kind); const auto plan = pp::app::plan_document_export_menu_action( kind, app.canvas != nullptr, !requires_license || app.check_license()); - - switch (plan.action) - { - case pp::app::DocumentExportMenuAction::show_jpeg_dialog: - app.dialog_export(".jpg"); - return true; - case pp::app::DocumentExportMenuAction::show_png_dialog: - app.dialog_export(".png"); - return true; - case pp::app::DocumentExportMenuAction::show_layers_dialog: - app.dialog_export_layers(); - return true; - case pp::app::DocumentExportMenuAction::show_cube_faces_dialog: - app.dialog_export_cube_faces(); - return true; - case pp::app::DocumentExportMenuAction::show_depth_dialog: - app.dialog_export_depth(); - return true; - case pp::app::DocumentExportMenuAction::show_animation_frames_dialog: - app.dialog_export_anim_frames(); - return true; - case pp::app::DocumentExportMenuAction::show_animation_mp4_dialog: - app.dialog_export_mp4(); - return true; - case pp::app::DocumentExportMenuAction::show_timelapse_dialog: - app.dialog_timelapse_export(); - return true; - case pp::app::DocumentExportMenuAction::show_license_disabled: - app.message_box("License", "This function is disabled in demo mode."); - return false; - case pp::app::DocumentExportMenuAction::unavailable_no_canvas: - return false; - } - - return false; + LegacyDocumentExportMenuServices services(app); + const auto status = pp::app::execute_document_export_menu_plan(plan, services); + if (!status.ok()) + LOG("Document export menu action failed: %s", status.message); + return status.ok() && plan.opens_dialog; } class LegacyFileMenuServices final : public pp::app::FileMenuServices { diff --git a/tests/app_core/document_export_tests.cpp b/tests/app_core/document_export_tests.cpp index 1bef8a9..8a33eef 100644 --- a/tests/app_core/document_export_tests.cpp +++ b/tests/app_core/document_export_tests.cpp @@ -3,6 +3,36 @@ namespace { +class FakeDocumentExportMenuServices final : public pp::app::DocumentExportMenuServices { +public: + void show_jpeg_dialog() override { jpeg_dialogs += 1; } + void show_png_dialog() override { png_dialogs += 1; } + void show_layers_dialog() override { layer_dialogs += 1; } + void show_cube_faces_dialog() override { cube_dialogs += 1; } + void show_depth_dialog() override { depth_dialogs += 1; } + void show_animation_frames_dialog() override { animation_frame_dialogs += 1; } + void show_animation_mp4_dialog() override { animation_mp4_dialogs += 1; } + void show_timelapse_dialog() override { timelapse_dialogs += 1; } + void show_license_disabled() override { license_messages += 1; } + + [[nodiscard]] int total_calls() const noexcept + { + return jpeg_dialogs + png_dialogs + layer_dialogs + cube_dialogs + depth_dialogs + + animation_frame_dialogs + animation_mp4_dialogs + timelapse_dialogs + + license_messages; + } + + int jpeg_dialogs = 0; + int png_dialogs = 0; + int layer_dialogs = 0; + int cube_dialogs = 0; + int depth_dialogs = 0; + int animation_frame_dialogs = 0; + int animation_mp4_dialogs = 0; + int timelapse_dialogs = 0; + int license_messages = 0; +}; + void equirectangular_export_builds_file_target(pp::tests::Harness& harness) { const auto target = pp::app::make_document_export_file_target("D:/Paint", "demo", ".png"); @@ -123,6 +153,106 @@ void export_menu_reports_missing_canvas_for_unlicensed_image_exports(pp::tests:: PP_EXPECT(harness, !plan.opens_dialog); } +void export_menu_executor_dispatches_all_dialog_actions(pp::tests::Harness& harness) +{ + FakeDocumentExportMenuServices services; + + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::jpeg, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::png, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::layers, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::cube_faces, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::depth, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::animation_frames, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::animation_mp4, + true, + true), + services).ok()); + PP_EXPECT( + harness, + pp::app::execute_document_export_menu_plan( + pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::timelapse, + true, + true), + services).ok()); + + PP_EXPECT(harness, services.jpeg_dialogs == 1); + PP_EXPECT(harness, services.png_dialogs == 1); + PP_EXPECT(harness, services.layer_dialogs == 1); + PP_EXPECT(harness, services.cube_dialogs == 1); + PP_EXPECT(harness, services.depth_dialogs == 1); + PP_EXPECT(harness, services.animation_frame_dialogs == 1); + PP_EXPECT(harness, services.animation_mp4_dialogs == 1); + PP_EXPECT(harness, services.timelapse_dialogs == 1); +} + +void export_menu_executor_preserves_blocked_and_unavailable_actions(pp::tests::Harness& harness) +{ + FakeDocumentExportMenuServices services; + + const auto blocked = pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::animation_mp4, + true, + false); + PP_EXPECT(harness, blocked.action == pp::app::DocumentExportMenuAction::show_license_disabled); + PP_EXPECT(harness, pp::app::execute_document_export_menu_plan(blocked, services).ok()); + PP_EXPECT(harness, services.license_messages == 1); + + const auto unavailable = pp::app::plan_document_export_menu_action( + pp::app::DocumentExportMenuKind::png, + false, + true); + PP_EXPECT(harness, unavailable.action == pp::app::DocumentExportMenuAction::unavailable_no_canvas); + PP_EXPECT(harness, pp::app::execute_document_export_menu_plan(unavailable, services).ok()); + PP_EXPECT(harness, services.total_calls() == 1); +} + } int main() @@ -139,5 +269,7 @@ int main() harness.run("export menu routes image dialogs without license", export_menu_routes_image_dialogs_without_license); harness.run("export menu blocks video when license is missing", export_menu_blocks_video_when_license_is_missing); harness.run("export menu reports missing canvas for unlicensed image exports", export_menu_reports_missing_canvas_for_unlicensed_image_exports); + harness.run("export menu executor dispatches all dialog actions", export_menu_executor_dispatches_all_dialog_actions); + harness.run("export menu executor preserves blocked and unavailable actions", export_menu_executor_preserves_blocked_and_unavailable_actions); return harness.finish(); }