diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 404b28f..8f8205e 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -395,15 +395,16 @@ Known local toolchain state: contract as JSON and is covered for project files, ABR imports, PPBR imports, and malformed path rejection. - `pano_cli simulate-app-session` exposes `pp_app_core` project-open, - app-close, save, save-as, and save-version decisions as JSON and is covered - for clean, dirty, already-prompting, new-document, save-as, save-version, and - dirty-save-version states. + app-close, save, save-as, save-version, and save-before-workflow decisions + as JSON and is covered for clean, dirty, already-prompting, missing-canvas, + new-document, save-as, save-version, and dirty-save-version states. - `pp_app_core_document_route_tests` covers the app document-open route contract for PPI/project files, ABR imports, PPBR imports, inner-dot names, and malformed paths before the live `App::open_document` performs UI or legacy canvas work. - `pp_app_core_document_session_tests` covers clean and dirty app session plus - save-request decisions without requiring a window, canvas, or message box. + save-request and save-before-workflow decisions without requiring a window, + canvas, or message box. - `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON` through the vcpkg preset; default and Android validation still use the retained vendored fallback tracked by DEBT-0012. diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index 68aae9a..e1e19a0 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -1,7 +1,7 @@ # PanoPainter Capability Map Status: live -Last updated: 2026-05-31 +Last updated: 2026-06-02 This map is the preservation checklist for the modernization. When a component is extracted, update the relevant rows with the owning component, test label, @@ -13,7 +13,7 @@ and validation command. | --- | --- | --- | --- | | PPI open/save | `Canvas`, serializer, dialogs | `pp_document`, `pp_assets`, `pano_cli` | Round-trip tiny project, old-version fixture, corrupt/truncated fixture | | Open-document routing | `App::open_document` | `pp_app_core`, `pano_cli`, `pp_panopainter_ui`, `pp_document`, `pp_assets` | Project/ABR/PPBR route tests, malformed path tests, CLI route smoke, app open smoke | -| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version decision tests, CLI session smoke, app close/open/save smoke | +| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow decision tests, CLI session smoke, app close/open/save/new/browse smoke | | Version metadata | `scripts/pre-build.py`, `version.*` | build system, `pp_foundation` | Generated header smoke test, missing-tag behavior | | Thumbnail generation/read | `Canvas`, `Image` | `pp_assets`, `pp_paint_renderer` | Golden thumbnail, corrupt input | | Save-as, overwrite prompts | App/dialogs | `pp_app_core`, `pp_panopainter_ui`, `pp_platform_*` | Decision tests, UI automation, and platform smoke | diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 21ebb5f..3d81586 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`, file-menu save actions, `NodeCanvas` save hotkeys, `pano_cli classify-open`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session contracts, but document loading and saving still reach legacy `Canvas::I` and UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `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`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, `pano_cli classify-open`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session contracts, but document loading and saving still reach legacy `Canvas::I` and UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `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 | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 69f0823..2542227 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -425,7 +425,8 @@ rejection smoke test for unsafe tiny canvas dimensions. contract for project files, ABR imports, PPBR imports, and malformed path rejection. `pano_cli simulate-app-session` exposes the pure `pp_app_core` session decisions used by project-open, app-close, save, save-as, and -save-version flows. +save-version flows, plus the save-before-continue workflow gate used by +new-document/open/browse dialogs. `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. diff --git a/src/app.h b/src/app.h index 30c862d..774f3fc 100644 --- a/src/app.h +++ b/src/app.h @@ -250,6 +250,7 @@ public: void dialog_changelog(); void dialog_about(); void save_document(pp::app::DocumentSaveIntent intent); + void continue_document_workflow_after_optional_save(std::function action); void dialog_newdoc(); void dialog_save(); void dialog_save_ver(); diff --git a/src/app_core/document_session.h b/src/app_core/document_session.h index 8e54ffa..5974b5f 100644 --- a/src/app_core/document_session.h +++ b/src/app_core/document_session.h @@ -27,6 +27,12 @@ enum class DocumentSaveDecision { save_version, }; +enum class DocumentWorkflowDecision { + unavailable, + continue_now, + prompt_save_before_continue, +}; + [[nodiscard]] constexpr ProjectOpenDecision plan_project_open(bool has_unsaved_changes) noexcept { return has_unsaved_changes @@ -78,4 +84,17 @@ enum class DocumentSaveDecision { return DocumentSaveDecision::no_op; } +[[nodiscard]] constexpr DocumentWorkflowDecision plan_document_workflow( + bool has_canvas, + bool has_unsaved_changes) noexcept +{ + if (!has_canvas) { + return DocumentWorkflowDecision::unavailable; + } + + return has_unsaved_changes + ? DocumentWorkflowDecision::prompt_save_before_continue + : DocumentWorkflowDecision::continue_now; +} + } diff --git a/src/app_dialogs.cpp b/src/app_dialogs.cpp index 61f9616..0cd5696 100644 --- a/src/app_dialogs.cpp +++ b/src/app_dialogs.cpp @@ -105,6 +105,41 @@ void App::dialog_about() layout[main_id]->add_child(dialog); } +void App::continue_document_workflow_after_optional_save(std::function action) +{ + const bool has_canvas = canvas != nullptr; + const bool has_unsaved_changes = has_canvas && Canvas::I->m_unsaved; + const auto decision = pp::app::plan_document_workflow(has_canvas, has_unsaved_changes); + switch (decision) { + case pp::app::DocumentWorkflowDecision::unavailable: + return; + case pp::app::DocumentWorkflowDecision::continue_now: + action(); + return; + case pp::app::DocumentWorkflowDecision::prompt_save_before_continue: + break; + } + + auto m = layout[main_id]->add_child(); + m->m_title->set_text("Unsaved document"); + m->m_message->set_text("Would you like to save this document before closing?"); + m->btn_ok->m_text->set_text("Yes"); + m->btn_cancel->m_text->set_text("No"); + m->btn_ok->on_click = [this, m, action](Node*) { + Canvas::I->project_save([this, m, action](bool success) { + if (success) + action(); + else + message_box("Saving Error", "There was a problem saving the document"); + }); + m->destroy(); + }; + m->btn_cancel->on_click = [m, action](Node*) { + action(); + m->destroy(); + }; +} + void App::dialog_newdoc() { auto show_dialog = [this] { @@ -181,34 +216,7 @@ void App::dialog_newdoc() }; }; - if (canvas) - { - if (Canvas::I->m_unsaved) - { - auto m = layout[main_id]->add_child(); - m->m_title->set_text("Unsaved document"); - m->m_message->set_text("Would you like to save this document before closing?"); - m->btn_ok->m_text->set_text("Yes"); - m->btn_cancel->m_text->set_text("No"); - m->btn_ok->on_click = [this, m, show_dialog](Node*) { - Canvas::I->project_save([this, m, show_dialog](bool success){ - if (success) - show_dialog(); - else - message_box("Saving Error", "There was a problem saving the document"); - }); - m->destroy(); - }; - m->btn_cancel->on_click = [this, m, show_dialog](Node*) { - show_dialog(); - m->destroy(); - }; - } - else - { - show_dialog(); - } - } + continue_document_workflow_after_optional_save(show_dialog); } // DEPRECATED @@ -242,34 +250,7 @@ void App::dialog_open() }; }; - if (canvas) - { - if (Canvas::I->m_unsaved) - { - auto m = layout[main_id]->add_child(); - m->m_title->set_text("Unsaved document"); - m->m_message->set_text("Would you like to save this document before closing?"); - m->btn_ok->m_text->set_text("Yes"); - m->btn_cancel->m_text->set_text("No"); - m->btn_ok->on_click = [this,m,show_dialog](Node*){ - Canvas::I->project_save([this,m,show_dialog](bool success){ - if (success) - show_dialog(); - else - message_box("Saving Error", "There was a problem saving the document"); - }); - m->destroy(); - }; - m->btn_cancel->on_click = [this,m,show_dialog](Node*) { - show_dialog(); - m->destroy(); - }; - } - else - { - show_dialog(); - } - } + continue_document_workflow_after_optional_save(show_dialog); } void App::dialog_browse() @@ -299,34 +280,7 @@ void App::dialog_browse() }; }; - if (canvas) - { - if (Canvas::I->m_unsaved) - { - auto m = layout[main_id]->add_child(); - m->m_title->set_text("Unsaved document"); - m->m_message->set_text("Would you like to save this document before closing?"); - m->btn_ok->m_text->set_text("Yes"); - m->btn_cancel->m_text->set_text("No"); - m->btn_ok->on_click = [this, m, show_dialog](Node*) { - Canvas::I->project_save([this, m, show_dialog](bool success){ - if (success) - show_dialog(); - else - message_box("Saving Error", "There was a problem saving the document"); - }); - m->destroy(); - }; - m->btn_cancel->on_click = [this, m, show_dialog](Node*) { - show_dialog(); - m->destroy(); - }; - } - else - { - show_dialog(); - } - } + continue_document_workflow_after_optional_save(show_dialog); } void App::dialog_save_ver() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 740f630..381f16f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -375,13 +375,13 @@ if(TARGET pano_cli) COMMAND pano_cli simulate-app-session) set_tests_properties(pano_cli_simulate_app_session_clean_smoke PROPERTIES LABELS "app;integration;desktop-fast" - PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"newDocument\":false.*\"unsaved\":false.*\"closePromptOpen\":false.*\"projectOpen\":\"open-now\".*\"closeRequest\":\"close-now\".*\"saveIntent\":\"save\".*\"saveRequest\":\"no-op\"") + PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"hasCanvas\":true.*\"newDocument\":false.*\"unsaved\":false.*\"closePromptOpen\":false.*\"projectOpen\":\"open-now\".*\"closeRequest\":\"close-now\".*\"saveIntent\":\"save\".*\"saveRequest\":\"no-op\".*\"workflowStart\":\"continue-now\"") add_test(NAME pano_cli_simulate_app_session_unsaved_smoke COMMAND pano_cli simulate-app-session --unsaved) set_tests_properties(pano_cli_simulate_app_session_unsaved_smoke PROPERTIES LABELS "app;integration;desktop-fast" - PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"newDocument\":false.*\"unsaved\":true.*\"closePromptOpen\":false.*\"projectOpen\":\"prompt-discard-unsaved\".*\"closeRequest\":\"show-unsaved-prompt\".*\"saveRequest\":\"save-existing\"") + PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"hasCanvas\":true.*\"newDocument\":false.*\"unsaved\":true.*\"closePromptOpen\":false.*\"projectOpen\":\"prompt-discard-unsaved\".*\"closeRequest\":\"show-unsaved-prompt\".*\"saveRequest\":\"save-existing\".*\"workflowStart\":\"prompt-save-before-continue\"") add_test(NAME pano_cli_simulate_app_session_existing_prompt_smoke COMMAND pano_cli simulate-app-session --unsaved --close-prompt-open) @@ -413,6 +413,12 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast" PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"saveIntent\":\"save-dirty-version\".*\"saveRequest\":\"no-op\"") + add_test(NAME pano_cli_simulate_app_session_no_canvas_smoke + COMMAND pano_cli simulate-app-session --no-canvas) + set_tests_properties(pano_cli_simulate_app_session_no_canvas_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"hasCanvas\":false.*\"workflowStart\":\"unavailable\"") + add_test(NAME pano_cli_save_project_roundtrip_smoke COMMAND "${CMAKE_COMMAND}" -DPANO_CLI=$ diff --git a/tests/app_core/document_session_tests.cpp b/tests/app_core/document_session_tests.cpp index 74b2e34..0db1587 100644 --- a/tests/app_core/document_session_tests.cpp +++ b/tests/app_core/document_session_tests.cpp @@ -87,6 +87,34 @@ void save_version_respects_menu_and_hotkey_behaviors(pp::tests::Harness& harness == pp::app::DocumentSaveDecision::show_save_dialog); } +void workflow_without_canvas_is_unavailable(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_document_workflow(false, false) + == pp::app::DocumentWorkflowDecision::unavailable); + PP_EXPECT( + harness, + pp::app::plan_document_workflow(false, true) + == pp::app::DocumentWorkflowDecision::unavailable); +} + +void workflow_with_clean_canvas_continues_now(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_document_workflow(true, false) + == pp::app::DocumentWorkflowDecision::continue_now); +} + +void workflow_with_dirty_canvas_prompts_for_save(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_document_workflow(true, true) + == pp::app::DocumentWorkflowDecision::prompt_save_before_continue); +} + } int main() @@ -100,5 +128,8 @@ int main() harness.run("save new or dirty document has user visible work", save_new_or_dirty_document_has_user_visible_work); harness.run("save as always shows save dialog", save_as_always_shows_save_dialog); harness.run("save version respects menu and hotkey behaviors", save_version_respects_menu_and_hotkey_behaviors); + harness.run("workflow without canvas is unavailable", workflow_without_canvas_is_unavailable); + harness.run("workflow with clean canvas continues now", workflow_with_clean_canvas_continues_now); + harness.run("workflow with dirty canvas prompts for save", workflow_with_dirty_canvas_prompts_for_save); return harness.finish(); } diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index bbe4871..d973b2e 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -97,6 +97,7 @@ struct ClassifyOpenArgs { }; struct SimulateAppSessionArgs { + bool has_canvas = true; bool new_document = false; bool unsaved = false; bool close_prompt_open = false; @@ -298,6 +299,20 @@ const char* document_save_decision_name(pp::app::DocumentSaveDecision decision) return "no-op"; } +const char* document_workflow_decision_name(pp::app::DocumentWorkflowDecision decision) noexcept +{ + switch (decision) { + case pp::app::DocumentWorkflowDecision::unavailable: + return "unavailable"; + case pp::app::DocumentWorkflowDecision::continue_now: + return "continue-now"; + case pp::app::DocumentWorkflowDecision::prompt_save_before_continue: + return "prompt-save-before-continue"; + } + + return "unavailable"; +} + pp::foundation::Result parse_float_arg(std::string_view text) { float value = 0.0F; @@ -336,7 +351,7 @@ void print_help() << " simulate-document-edits [--width N] [--height N]\n" << " simulate-document-export [--width N] [--height N]\n" << " simulate-document-history [--width N] [--height N] [--history N]\n" - << " simulate-app-session [--new-document] [--unsaved] [--close-prompt-open] [--save-intent save|save-as|save-version|save-dirty-version]\n" + << " simulate-app-session [--no-canvas] [--new-document] [--unsaved] [--close-prompt-open] [--save-intent save|save-as|save-version|save-dirty-version]\n" << " simulate-blend\n" << " simulate-image-import [--width N] [--height N]\n" << " simulate-stroke --x1 N --y1 N --x2 N --y2 N [--spacing N]\n" @@ -1178,6 +1193,8 @@ pp::foundation::Status parse_simulate_app_session_args( const std::string_view key(argv[i]); if (key == "--new-document") { args.new_document = true; + } else if (key == "--no-canvas") { + args.has_canvas = false; } else if (key == "--unsaved") { args.unsaved = true; } else if (key == "--close-prompt-open") { @@ -1221,8 +1238,10 @@ int simulate_app_session(int argc, char** argv) args.new_document, args.unsaved, args.save_intent); + const auto workflow_decision = pp::app::plan_document_workflow(args.has_canvas, args.unsaved); std::cout << "{\"ok\":true,\"command\":\"simulate-app-session\"" - << ",\"state\":{\"newDocument\":" << json_bool(args.new_document) + << ",\"state\":{\"hasCanvas\":" << json_bool(args.has_canvas) + << ",\"newDocument\":" << json_bool(args.new_document) << ",\"unsaved\":" << json_bool(args.unsaved) << ",\"closePromptOpen\":" << json_bool(args.close_prompt_open) << "},\"decisions\":{\"projectOpen\":\"" @@ -1233,6 +1252,8 @@ int simulate_app_session(int argc, char** argv) << document_save_intent_name(args.save_intent) << "\",\"saveRequest\":\"" << document_save_decision_name(save_decision) + << "\",\"workflowStart\":\"" + << document_workflow_decision_name(workflow_decision) << "\"}}\n"; return 0; }