diff --git a/cmake/PanoPainterSources.cmake b/cmake/PanoPainterSources.cmake index e3c78a9..e7ed526 100644 --- a/cmake/PanoPainterSources.cmake +++ b/cmake/PanoPainterSources.cmake @@ -84,6 +84,8 @@ set(PP_PANOPAINTER_APP_SOURCES src/app_vr.cpp src/legacy_cloud_services.cpp src/legacy_cloud_services.h + src/legacy_document_open_services.cpp + src/legacy_document_open_services.h src/platform_legacy/legacy_platform_services.cpp src/platform_legacy/legacy_platform_services.h src/version.cpp diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 3480d3c..dc9ee20 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -583,10 +583,15 @@ Known local toolchain state: decisions, plus bulk upload progress visibility, zero-file, and clamped progress-total decisions. - `pp_app_core_document_session_tests` covers clean and dirty app session, - document-open action planning, save-request, save-before-workflow, - new-document target/resolution/overwrite planning, document file target, - combined save-file overwrite planning, and save-version target decisions - without requiring a window, canvas, or message box. + document-open action planning and executor dispatch/rejection, save-request, + save-before-workflow, new-document target/resolution/overwrite planning, + document file target, combined save-file overwrite planning, and save-version + target decisions without requiring a window, canvas, or message box. +- `src/legacy_document_open_services.*` is the current app-shell bridge between + `pp_app_core` document-open plans and live ABR/PPBR import prompts, + unsaved-project discard prompts, project opening, layer UI refresh, title + updates, and action-history clearing; remaining legacy execution ownership is + tracked by `DEBT-0039`. - `src/legacy_history_services.*` is the current app-shell bridge between `pp_app_core` history plans and legacy `ActionManager`; toolbar and `NodeCanvas` hotkeys share it while document-history extraction remains diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 9050cc3..085b3e0 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -56,6 +56,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates and destination-copy versus framebuffer-fetch decisions. `pp_paint_renderer::plan_canvas_stroke_feedback` also owns the current destination-feedback decision, and live `Canvas::stroke_draw`, thumbnail layer blending, and `NodeStrokePreview` brush-preview rendering use it for framebuffer-fetch versus destination-copy decisions. Actual live stroke rasterization, dual-brush compositing, pattern feedback math, thumbnail layer compositing, and brush-preview compositing still use legacy OpenGL canvas/UI execution | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior | | DEBT-0037 | Open | Modernization | Recording lifecycle/export planning and execution dispatch now consume pure `pp_app_core` through `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, `pano_cli plan-recording-session`, and the `RecordingServices` boundary; live execution is centralized in `src/legacy_recording_services.*`, but the bridge still owns legacy recording thread startup/shutdown, platform recorded-file cleanup, progress UI, PBO readback through `App::rec_loop`, and `MP4Encoder::write_mp4` execution | Preserve current timelapse/MP4 behavior while recording moves toward app/document/renderer/video services | `pp_app_core_document_recording_tests`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-recording-session --platform-clears-files`; `ctest --preset desktop-fast --build-config Debug` | Recording thread lifecycle, frame readback, platform cleanup, progress reporting, and MP4 writing are owned by injected app/renderer/video services with `App` methods acting only as adapters | | DEBT-0038 | Open | Modernization | Cloud upload/browse/bulk planning and execution dispatch now consume pure `pp_app_core` through `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-upload-all`, `pano_cli plan-cloud-browse`, and the `CloudServices` boundary; live execution is centralized in `src/legacy_cloud_services.*`, but the bridge still uses legacy save-before-upload, `upload`/`download` network helpers, progress/message UI, OpenGL context guarding, `NodeDialogCloud`, `Canvas` project open, layer refresh, and `ActionManager` reset | Preserve current cloud behavior while cloud/network/document import flows move toward app/document/platform services | `pp_app_core_document_cloud_tests`; `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`; `ctest --preset desktop-fast --build-config Debug` | Cloud upload/download, save-before-upload, progress reporting, cloud browse dialog, downloaded project opening, layer refresh, OpenGL context ownership, and action-history reset are owned by injected app/document/network/platform/renderer services with `App` methods acting only as adapters | +| DEBT-0039 | Open | Modernization | Document-open planning and execution dispatch now consume pure `pp_app_core` through `App::open_document`, `pano_cli plan-open-route`, `DocumentOpenServices`, and `src/legacy_document_open_services.*`, but the bridge still opens ABR/PPBR import prompts, launches legacy brush preset import threads, applies unsaved-project discard prompts, calls legacy project-open execution, refreshes layer UI, updates the app title, and clears legacy history directly | Preserve current file-open/import behavior while document loading and brush import move toward app/document/asset/UI services | `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli plan-open-route --path D:/Paint/Scenes/demo.ppi --unsaved`; `pano_cli plan-open-route --path D:/Paint/Brushes/clouds.ABR --unsaved`; `ctest --preset desktop-fast --build-config Debug` | Brush import prompting/execution, project-open execution, unsaved-project discard prompting, layer refresh, title updates, and history clearing are owned by injected app/document/asset/UI services with `App::open_document` acting only as an adapter | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 4332d05..6fd5946 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -728,6 +728,11 @@ layer-refresh, and action-history work remains tracked under `DEBT-0038`. `pano_cli parse-layout` exercises the XML layout path. Continue expanding document behavior toward legacy Canvas parity and then port OpenGL classes behind the renderer boundary. +`App::open_document` now routes through the app-core document-open executor and +`src/legacy_document_open_services.*`, preserving ABR/PPBR import prompts, +unsaved-project discard prompts, project open, layer refresh, title updates, +and history clearing while those live effects remain tracked under +`DEBT-0039`. Implementation tasks: @@ -1260,6 +1265,13 @@ Results: - `scripts/automation/package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` passed executable/data checks after the cloud bridge split; package target migration blockers remain under `DEBT-0011`. +- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built + after `App::open_document` moved live execution behind the document-open + services bridge. A clean rebuild was required once because MSVC reported the + known Debug PDB `LNK1103` corruption, after which the build passed. +- Focused document-open CTest coverage passed for + `pp_app_core_document_route_tests`, `pp_app_core_document_session_tests`, and + the `pano_cli_plan_open_route_*` smoke tests after the live bridge split. - `pp_app_core_document_recording_tests` passed, covering recording start/stop, clear, platform recorded-file cleanup, frame-count reset, export progress totals, and oversized progress-total clamping. diff --git a/src/app.cpp b/src/app.cpp index eac1df2..fb33336 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -10,7 +10,7 @@ #include "app_core/document_recording.h" #include "app_core/document_route.h" #include "app_core/document_session.h" -#include "legacy_history_services.h" +#include "legacy_document_open_services.h" #include "legacy_recording_services.h" #include "platform_api/platform_services.h" #include "renderer_gl/opengl_capabilities.h" @@ -197,65 +197,9 @@ void App::open_document(std::string path) const bool has_unsaved_project = route.value().kind == pp::app::DocumentOpenKind::open_project && Canvas::I->m_unsaved; const auto open_plan = pp::app::plan_document_open(route.value().kind, has_unsaved_project); - if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_abr) - { - auto mb = message_box("Import ABR", "Would you like to import the brushes?", true); - mb->on_submit = [this, path] (Node* target) { - std::thread(&NodePanelBrushPreset::import_abr, presets, path).detach(); - target->destroy(); - }; - } - else if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_ppbr) - { - auto mb = message_box("Import PPBR", "Would you like to import the brushes?", true); - mb->on_submit = [this, path] (Node* target) { - std::thread(&NodePanelBrushPreset::import_ppbr, presets, path).detach(); - target->destroy(); - }; - } - else - { - const std::string base = route.value().directory; - const std::string name = route.value().name; - auto open_action = [this, path, base, name] { - doc_name = name; - doc_dir = base; - doc_path = path; - canvas->reset_camera(); - layers->clear(); - canvas->m_canvas->project_open(path, [this](bool success){ - // on complete - if (success) - { - title_update(); - for (int layer_index = 0; layer_index < canvas->m_canvas->m_layers.size(); layer_index++) - { - auto l = layers->add_layer(canvas->m_canvas->m_layers[layer_index]->m_name.c_str(), false); - l->m_visibility->set_value(canvas->m_canvas->m_layers[layer_index]->m_visible); - } - } - else - { - message_box("Open Document Error", - "There was an error opening the document.\n" - "It may be inaccessible or corrupted."); - } - }); - pp::panopainter::clear_legacy_history(); - }; - if (open_plan == pp::app::DocumentOpenPlanAction::open_project_now) - { - open_action(); - } - else - { - auto mb = message_box("Unsaved document", "Do you want to close the unsaved document before opening the file?", true); - mb->on_submit = [this, open_action] (Node* target) { - open_action(); - target->destroy(); - }; - } - } + const auto status = pp::panopainter::execute_legacy_document_open_plan(*this, open_plan, route.value()); + if (!status.ok()) + LOG("Document open action failed: %s", status.message); } bool App::request_close() diff --git a/src/app_core/document_session.h b/src/app_core/document_session.h index 7601269..bee85b2 100644 --- a/src/app_core/document_session.h +++ b/src/app_core/document_session.h @@ -55,6 +55,16 @@ enum class DocumentOpenPlanAction { prompt_import_ppbr, }; +class DocumentOpenServices { +public: + virtual ~DocumentOpenServices() = default; + + virtual void prompt_import_abr(const DocumentOpenRoute& route) = 0; + virtual void prompt_import_ppbr(const DocumentOpenRoute& route) = 0; + virtual void open_project_now(const DocumentOpenRoute& route) = 0; + virtual void prompt_discard_unsaved_project(const DocumentOpenRoute& route) = 0; +}; + struct DocumentFileTarget { std::string name; std::string directory; @@ -102,6 +112,41 @@ struct NewDocumentPlan { return DocumentOpenPlanAction::open_project_now; } +[[nodiscard]] inline pp::foundation::Status execute_document_open_plan( + DocumentOpenPlanAction action, + const DocumentOpenRoute& route, + DocumentOpenServices& services) +{ + switch (action) { + case DocumentOpenPlanAction::open_project_now: + if (route.kind != DocumentOpenKind::open_project) { + return pp::foundation::Status::invalid_argument("open-project action requires a project route"); + } + services.open_project_now(route); + return pp::foundation::Status::success(); + case DocumentOpenPlanAction::prompt_discard_unsaved_project: + if (route.kind != DocumentOpenKind::open_project) { + return pp::foundation::Status::invalid_argument("discard prompt requires a project route"); + } + services.prompt_discard_unsaved_project(route); + return pp::foundation::Status::success(); + case DocumentOpenPlanAction::prompt_import_abr: + if (route.kind != DocumentOpenKind::import_abr) { + return pp::foundation::Status::invalid_argument("ABR import prompt requires an ABR route"); + } + services.prompt_import_abr(route); + return pp::foundation::Status::success(); + case DocumentOpenPlanAction::prompt_import_ppbr: + if (route.kind != DocumentOpenKind::import_ppbr) { + return pp::foundation::Status::invalid_argument("PPBR import prompt requires a PPBR route"); + } + services.prompt_import_ppbr(route); + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown document open action"); +} + [[nodiscard]] constexpr CloseRequestDecision plan_close_request( bool has_unsaved_changes, bool close_prompt_already_open) noexcept diff --git a/src/legacy_document_open_services.cpp b/src/legacy_document_open_services.cpp new file mode 100644 index 0000000..ad2da4a --- /dev/null +++ b/src/legacy_document_open_services.cpp @@ -0,0 +1,101 @@ +#include "pch.h" + +#include "legacy_document_open_services.h" + +#include "app.h" +#include "legacy_history_services.h" +#include "node_panel_brush.h" +#include "node_panel_layer.h" + +namespace pp::panopainter { +namespace { + +void open_legacy_project(App& app, const pp::app::DocumentOpenRoute& route) +{ + app.doc_name = route.name; + app.doc_dir = route.directory; + app.doc_path = route.path; + app.canvas->reset_camera(); + app.layers->clear(); + app.canvas->m_canvas->project_open(route.path, [&app](bool success) { + if (success) + { + app.title_update(); + for (std::size_t layer_index = 0; layer_index < app.canvas->m_canvas->m_layers.size(); ++layer_index) + { + auto layer = app.layers->add_layer(app.canvas->m_canvas->m_layers[layer_index]->m_name.c_str(), false); + layer->m_visibility->set_value(app.canvas->m_canvas->m_layers[layer_index]->m_visible); + } + } + else + { + app.message_box( + "Open Document Error", + "There was an error opening the document.\n" + "It may be inaccessible or corrupted."); + } + }); + pp::panopainter::clear_legacy_history(); +} + +class LegacyDocumentOpenServices final : public pp::app::DocumentOpenServices { +public: + explicit LegacyDocumentOpenServices(App& app) noexcept + : app_(app) + { + } + + void prompt_import_abr(const pp::app::DocumentOpenRoute& route) override + { + auto* app = &app_; + auto mb = app_.message_box("Import ABR", "Would you like to import the brushes?", true); + mb->on_submit = [app, path = route.path](Node* target) { + std::thread(&NodePanelBrushPreset::import_abr, app->presets, path).detach(); + target->destroy(); + }; + } + + void prompt_import_ppbr(const pp::app::DocumentOpenRoute& route) override + { + auto* app = &app_; + auto mb = app_.message_box("Import PPBR", "Would you like to import the brushes?", true); + mb->on_submit = [app, path = route.path](Node* target) { + std::thread(&NodePanelBrushPreset::import_ppbr, app->presets, path).detach(); + target->destroy(); + }; + } + + void open_project_now(const pp::app::DocumentOpenRoute& route) override + { + open_legacy_project(app_, route); + } + + void prompt_discard_unsaved_project(const pp::app::DocumentOpenRoute& route) override + { + auto* app = &app_; + auto mb = app_.message_box( + "Unsaved document", + "Do you want to close the unsaved document before opening the file?", + true); + mb->on_submit = [app, route](Node* target) { + open_legacy_project(*app, route); + target->destroy(); + }; + } + +private: + App& app_; +}; + +} // namespace + +pp::foundation::Status execute_legacy_document_open_plan( + App& app, + pp::app::DocumentOpenPlanAction action, + const pp::app::DocumentOpenRoute& route) +{ + LegacyDocumentOpenServices services(app); + return pp::app::execute_document_open_plan(action, route, services); +} + +} // namespace pp::panopainter diff --git a/src/legacy_document_open_services.h b/src/legacy_document_open_services.h new file mode 100644 index 0000000..cb69ae6 --- /dev/null +++ b/src/legacy_document_open_services.h @@ -0,0 +1,15 @@ +#pragma once + +#include "app_core/document_session.h" +#include "foundation/result.h" + +class App; + +namespace pp::panopainter { + +[[nodiscard]] pp::foundation::Status execute_legacy_document_open_plan( + App& app, + pp::app::DocumentOpenPlanAction action, + const pp::app::DocumentOpenRoute& route); + +} // namespace pp::panopainter diff --git a/tests/app_core/document_session_tests.cpp b/tests/app_core/document_session_tests.cpp index 9c95e89..9209a55 100644 --- a/tests/app_core/document_session_tests.cpp +++ b/tests/app_core/document_session_tests.cpp @@ -1,8 +1,81 @@ #include "app_core/document_session.h" #include "test_harness.h" +#include + namespace { +class FakeDocumentOpenServices final : public pp::app::DocumentOpenServices { +public: + void prompt_import_abr(const pp::app::DocumentOpenRoute& route) override + { + abr_prompts += 1; + last_path = route.path; + call_order += "abr;"; + } + + void prompt_import_ppbr(const pp::app::DocumentOpenRoute& route) override + { + ppbr_prompts += 1; + last_path = route.path; + call_order += "ppbr;"; + } + + void open_project_now(const pp::app::DocumentOpenRoute& route) override + { + project_opens += 1; + last_path = route.path; + call_order += "open;"; + } + + void prompt_discard_unsaved_project(const pp::app::DocumentOpenRoute& route) override + { + discard_prompts += 1; + last_path = route.path; + call_order += "discard;"; + } + + int abr_prompts = 0; + int ppbr_prompts = 0; + int project_opens = 0; + int discard_prompts = 0; + std::string last_path; + std::string call_order; +}; + +[[nodiscard]] pp::app::DocumentOpenRoute project_route() +{ + return { + .kind = pp::app::DocumentOpenKind::open_project, + .path = "D:/Paint/demo.ppi", + .directory = "D:/Paint", + .name = "demo", + .extension = "ppi", + }; +} + +[[nodiscard]] pp::app::DocumentOpenRoute abr_route() +{ + return { + .kind = pp::app::DocumentOpenKind::import_abr, + .path = "D:/Paint/clouds.abr", + .directory = "D:/Paint", + .name = "clouds", + .extension = "abr", + }; +} + +[[nodiscard]] pp::app::DocumentOpenRoute ppbr_route() +{ + return { + .kind = pp::app::DocumentOpenKind::import_ppbr, + .path = "D:/Paint/brush.ppbr", + .directory = "D:/Paint", + .name = "brush", + .extension = "ppbr", + }; +} + void project_open_clean_document_executes_immediately(pp::tests::Harness& harness) { PP_EXPECT(harness, pp::app::plan_project_open(false) == pp::app::ProjectOpenDecision::open_now); @@ -47,6 +120,78 @@ void document_open_brush_imports_prompt_regardless_of_unsaved_state(pp::tests::H == pp::app::DocumentOpenPlanAction::prompt_import_ppbr); } +void document_open_executor_dispatches_all_actions(pp::tests::Harness& harness) +{ + FakeDocumentOpenServices services; + + const auto project = project_route(); + const auto abr = abr_route(); + const auto ppbr = ppbr_route(); + PP_EXPECT( + harness, + pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::open_project_now, + project, + services) + .ok()); + PP_EXPECT( + harness, + pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::prompt_discard_unsaved_project, + project, + services) + .ok()); + PP_EXPECT( + harness, + pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::prompt_import_abr, + abr, + services) + .ok()); + PP_EXPECT( + harness, + pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::prompt_import_ppbr, + ppbr, + services) + .ok()); + + PP_EXPECT(harness, services.project_opens == 1); + PP_EXPECT(harness, services.discard_prompts == 1); + PP_EXPECT(harness, services.abr_prompts == 1); + PP_EXPECT(harness, services.ppbr_prompts == 1); + PP_EXPECT(harness, services.last_path == "D:/Paint/brush.ppbr"); + PP_EXPECT(harness, services.call_order == "open;discard;abr;ppbr;"); +} + +void document_open_executor_rejects_mismatched_routes(pp::tests::Harness& harness) +{ + FakeDocumentOpenServices services; + + PP_EXPECT( + harness, + !pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::open_project_now, + abr_route(), + services) + .ok()); + PP_EXPECT( + harness, + !pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::prompt_import_abr, + project_route(), + services) + .ok()); + PP_EXPECT( + harness, + !pp::app::execute_document_open_plan( + pp::app::DocumentOpenPlanAction::prompt_import_ppbr, + abr_route(), + services) + .ok()); + PP_EXPECT(harness, services.call_order.empty()); +} + void close_clean_document_executes_immediately(pp::tests::Harness& harness) { PP_EXPECT( @@ -335,6 +480,8 @@ int main() harness.run( "document open brush imports prompt regardless of unsaved state", document_open_brush_imports_prompt_regardless_of_unsaved_state); + harness.run("document open executor dispatches all actions", document_open_executor_dispatches_all_actions); + harness.run("document open executor rejects mismatched routes", document_open_executor_rejects_mismatched_routes); harness.run("close clean document executes immediately", close_clean_document_executes_immediately); harness.run("close dirty document opens one prompt", close_dirty_document_opens_one_prompt); harness.run("save clean existing document is no op", save_clean_existing_document_is_no_op);