diff --git a/CMakeLists.txt b/CMakeLists.txt index 02e8ff1..92c47b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -211,7 +211,8 @@ target_link_libraries(pp_ui_core pp_project_warnings) add_library(pp_app_core STATIC - src/app_core/document_route.cpp) + src/app_core/document_route.cpp + src/app_core/document_session.cpp) target_include_directories(pp_app_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index eb731ed..d8c6069 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -10,7 +10,7 @@ Keep it updated as platform paths move to shared CMake targets. | Platform/Target | Current Entrypoint | Notes | | --- | --- | --- | -| Windows desktop | Root `CMakeLists.txt`, preset `windows-msvc-default`; target preset `windows-vs2026-x64` retained for VS 2026 | Raw `.sln/.vcxproj` files removed on 2026-05-31; local machine currently uses Visual Studio 17 2022; `PanoPainter` now links through `pp_platform_windows` and `panopainter_app`, with Windows/vendor link dependencies owned by the platform shell, runtime payload deployment in `cmake/PanoPainterRuntime.cmake`, tested app-level document-open routing owned by `pp_app_core`, retained third-party source dependencies contained by `pp_legacy_vendor`, retained asset/file/serialization sources contained by `pp_legacy_assets_io`, retained paint/document/canvas sources contained by `pp_legacy_paint_document`, retained OpenGL runtime sources contained by `pp_legacy_renderer_gl` and folded into `pp_legacy_engine`, retained runtime shell sources contained by `pp_legacy_engine`, retained base UI controls contained by `pp_legacy_ui_core` and folded into `pp_legacy_app`, app orchestration/version metadata owned by `panopainter_app`, and app-specific modal/dialog/panel/canvas workflow nodes owned by `pp_panopainter_ui` | +| Windows desktop | Root `CMakeLists.txt`, preset `windows-msvc-default`; target preset `windows-vs2026-x64` retained for VS 2026 | Raw `.sln/.vcxproj` files removed on 2026-05-31; local machine currently uses Visual Studio 17 2022; `PanoPainter` now links through `pp_platform_windows` and `panopainter_app`, with Windows/vendor link dependencies owned by the platform shell, runtime payload deployment in `cmake/PanoPainterRuntime.cmake`, tested app-level document-open routing and unsaved-document session decisions owned by `pp_app_core`, retained third-party source dependencies contained by `pp_legacy_vendor`, retained asset/file/serialization sources contained by `pp_legacy_assets_io`, retained paint/document/canvas sources contained by `pp_legacy_paint_document`, retained OpenGL runtime sources contained by `pp_legacy_renderer_gl` and folded into `pp_legacy_engine`, retained runtime shell sources contained by `pp_legacy_engine`, retained base UI controls contained by `pp_legacy_ui_core` and folded into `pp_legacy_app`, app orchestration/version metadata owned by `panopainter_app`, and app-specific modal/dialog/panel/canvas workflow nodes owned by `pp_panopainter_ui` | | Windows AppX | `PanoPainterPackage/Package.appxmanifest`, `.wapproj` referenced by solution | Distribution packaging | | macOS | `PanoPainter-OSX/` project files and `Info.plist` | Uses `NSOpenGLView` today | | iOS | `PanoPainter/Info.plist`, related Apple sources | Uses OpenGL ES today | @@ -394,10 +394,15 @@ Known local toolchain state: - `pano_cli classify-open` exposes the `pp_app_core` document-open route 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` unsaved-document + decisions for project-open and app-close flows as JSON and is covered for + clean, dirty, and already-prompting 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 + 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 1c24d38..1cace97 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -13,6 +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 | +| Unsaved document decisions | `App::open_document`, `App::request_close`, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open decision tests, CLI session smoke, app close/open 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_panopainter_ui`, `pp_platform_*` | UI automation and platform smoke | diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index aa3a31a..97bd271 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` and `pano_cli classify-open` now consume a pure `pp_app_core` route contract, but document loading still reaches legacy `Canvas::I` and UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `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`, `pano_cli classify-open`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session contracts, but document loading still reaches 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`; `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 b456fad..d9cb1c8 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -168,9 +168,12 @@ targets. `pp_app_core` now owns tested app-level document-open routing for project files, ABR imports, and PPBR imports without UI, filesystem, platform, or renderer dependencies; `App::open_document` and `pano_cli classify-open` -consume this route contract while legacy canvas/project loading remains in -place. `panopainter_app` is now a real static target that owns app -orchestration sources, app version metadata, and version-header generation. +consume this route contract. It also owns tested unsaved-document decisions for +project-open and app-close flows; `App::open_document`, `App::request_close`, +and `pano_cli simulate-app-session` consume those contracts while legacy +canvas/project loading remains in place. `panopainter_app` is now a real +static target that owns app orchestration sources, app version metadata, and +version-header generation. `pp_panopainter_ui` now owns app-specific modal, dialog, panel, canvas, viewport, color-picker, stroke-preview, and tool UI workflow nodes outside `pp_legacy_app`; base `Node` controls and layout plumbing remain in the legacy @@ -283,11 +286,11 @@ Status: in progress. `tests/` exists, `desktop-fast`, `fuzz`, and `stress` CTest presets run headlessly, and PowerShell/bash wrappers exist for configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists -with JSON automation commands for app document-open routing, creating a -`pp_document` model, metadata-only PPI project loading, and inspecting image -signatures, PPI headers, and layout XML; full document/app integration is -debt-tracked as DEBT-0010 and full PPI body parsing is debt-tracked as -DEBT-0013. +with JSON automation commands for app document-open routing, app session +dirty-state decisions, creating a `pp_document` model, metadata-only PPI +project loading, and inspecting image signatures, PPI headers, and layout XML; +full document/app integration is debt-tracked as DEBT-0010 and full PPI body +parsing is debt-tracked as DEBT-0013. Implementation tasks: @@ -419,9 +422,11 @@ the applied stroke payload survives inspect/load round-trip automation, with a rejection smoke test for unsafe tiny canvas dimensions. `pano_cli classify-open` exposes the pure `pp_app_core` document-open route contract for project files, ABR imports, PPBR imports, and malformed path -rejection. `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. +rejection. `pano_cli simulate-app-session` exposes the pure `pp_app_core` +unsaved-document decisions used by project-open and app-close flows. +`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. Implementation tasks: diff --git a/src/app.cpp b/src/app.cpp index 3d173e5..b142fbe 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include "node_progress_bar.h" #include "mp4enc.h" #include "app_core/document_route.h" +#include "app_core/document_session.h" #include "renderer_gl/opengl_capabilities.h" #ifdef __APPLE__ @@ -238,7 +239,8 @@ void App::open_document(std::string path) }); ActionManager::clear(); }; - if (!Canvas::I->m_unsaved) + const auto open_decision = pp::app::plan_project_open(Canvas::I->m_unsaved); + if (open_decision == pp::app::ProjectOpenDecision::open_now) { open_action(); } @@ -256,9 +258,12 @@ void App::open_document(std::string path) bool App::request_close() { static bool dialog_already_opened = false; - if (!Canvas::I->m_unsaved) + const auto close_decision = pp::app::plan_close_request( + Canvas::I->m_unsaved, + dialog_already_opened); + if (close_decision == pp::app::CloseRequestDecision::close_now) return true; - if (!dialog_already_opened) + if (close_decision == pp::app::CloseRequestDecision::show_unsaved_prompt) { auto* m = layout[main_id]->add_child(); m->m_title->set_text("Unsaved document"); diff --git a/src/app_core/document_session.cpp b/src/app_core/document_session.cpp new file mode 100644 index 0000000..fa71c74 --- /dev/null +++ b/src/app_core/document_session.cpp @@ -0,0 +1 @@ +#include "app_core/document_session.h" diff --git a/src/app_core/document_session.h b/src/app_core/document_session.h new file mode 100644 index 0000000..1c1ae38 --- /dev/null +++ b/src/app_core/document_session.h @@ -0,0 +1,36 @@ +#pragma once + +namespace pp::app { + +enum class ProjectOpenDecision { + open_now, + prompt_discard_unsaved, +}; + +enum class CloseRequestDecision { + close_now, + show_unsaved_prompt, + wait_for_existing_prompt, +}; + +[[nodiscard]] constexpr ProjectOpenDecision plan_project_open(bool has_unsaved_changes) noexcept +{ + return has_unsaved_changes + ? ProjectOpenDecision::prompt_discard_unsaved + : ProjectOpenDecision::open_now; +} + +[[nodiscard]] constexpr CloseRequestDecision plan_close_request( + bool has_unsaved_changes, + bool close_prompt_already_open) noexcept +{ + if (!has_unsaved_changes) { + return CloseRequestDecision::close_now; + } + + return close_prompt_already_open + ? CloseRequestDecision::wait_for_existing_prompt + : CloseRequestDecision::show_unsaved_prompt; +} + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0584231..e87f22a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -268,6 +268,16 @@ add_test(NAME pp_app_core_document_route_tests COMMAND pp_app_core_document_rout set_tests_properties(pp_app_core_document_route_tests PROPERTIES LABELS "app;desktop-fast;fuzz") +add_executable(pp_app_core_document_session_tests + app_core/document_session_tests.cpp) +target_link_libraries(pp_app_core_document_session_tests PRIVATE + pp_app_core + pp_test_harness) + +add_test(NAME pp_app_core_document_session_tests COMMAND pp_app_core_document_session_tests) +set_tests_properties(pp_app_core_document_session_tests PROPERTIES + LABELS "app;desktop-fast") + if(TARGET pano_cli) add_test(NAME pano_cli_create_document_smoke COMMAND pano_cli create-document --width 64 --height 32 --layers 2) @@ -361,6 +371,24 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast;fuzz" ) + add_test(NAME pano_cli_simulate_app_session_clean_smoke + 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\".*\"unsaved\":false.*\"closePromptOpen\":false.*\"projectOpen\":\"open-now\".*\"closeRequest\":\"close-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\".*\"unsaved\":true.*\"closePromptOpen\":false.*\"projectOpen\":\"prompt-discard-unsaved\".*\"closeRequest\":\"show-unsaved-prompt\"") + + add_test(NAME pano_cli_simulate_app_session_existing_prompt_smoke + COMMAND pano_cli simulate-app-session --unsaved --close-prompt-open) + set_tests_properties(pano_cli_simulate_app_session_existing_prompt_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"simulate-app-session\".*\"unsaved\":true.*\"closePromptOpen\":true.*\"projectOpen\":\"prompt-discard-unsaved\".*\"closeRequest\":\"wait-for-existing-prompt\"") + 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 new file mode 100644 index 0000000..d2f6b9a --- /dev/null +++ b/tests/app_core/document_session_tests.cpp @@ -0,0 +1,48 @@ +#include "app_core/document_session.h" +#include "test_harness.h" + +namespace { + +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); +} + +void project_open_dirty_document_prompts_for_discard(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_project_open(true) == pp::app::ProjectOpenDecision::prompt_discard_unsaved); +} + +void close_clean_document_executes_immediately(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_close_request(false, false) == pp::app::CloseRequestDecision::close_now); + PP_EXPECT( + harness, + pp::app::plan_close_request(false, true) == pp::app::CloseRequestDecision::close_now); +} + +void close_dirty_document_opens_one_prompt(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_close_request(true, false) == pp::app::CloseRequestDecision::show_unsaved_prompt); + PP_EXPECT( + harness, + pp::app::plan_close_request(true, true) == pp::app::CloseRequestDecision::wait_for_existing_prompt); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("project open clean document executes immediately", project_open_clean_document_executes_immediately); + harness.run("project open dirty document prompts for discard", project_open_dirty_document_prompts_for_discard); + 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); + return harness.finish(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 1e22f65..78bea2d 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -1,4 +1,5 @@ #include "app_core/document_route.h" +#include "app_core/document_session.h" #include "assets/image_format.h" #include "assets/image_metadata.h" #include "assets/image_pixels.h" @@ -95,6 +96,11 @@ struct ClassifyOpenArgs { std::string path; }; +struct SimulateAppSessionArgs { + bool unsaved = false; + bool close_prompt_open = false; +}; + struct SimulateStrokeArgs { std::uint32_t x1 = 0; std::uint32_t y1 = 0; @@ -232,6 +238,32 @@ const char* document_open_kind_name(pp::app::DocumentOpenKind kind) noexcept return "open-project"; } +const char* project_open_decision_name(pp::app::ProjectOpenDecision decision) noexcept +{ + switch (decision) { + case pp::app::ProjectOpenDecision::open_now: + return "open-now"; + case pp::app::ProjectOpenDecision::prompt_discard_unsaved: + return "prompt-discard-unsaved"; + } + + return "open-now"; +} + +const char* close_request_decision_name(pp::app::CloseRequestDecision decision) noexcept +{ + switch (decision) { + case pp::app::CloseRequestDecision::close_now: + return "close-now"; + case pp::app::CloseRequestDecision::show_unsaved_prompt: + return "show-unsaved-prompt"; + case pp::app::CloseRequestDecision::wait_for_existing_prompt: + return "wait-for-existing-prompt"; + } + + return "close-now"; +} + pp::foundation::Result parse_float_arg(std::string_view text) { float value = 0.0F; @@ -270,6 +302,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 [--unsaved] [--close-prompt-open]\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" @@ -1102,6 +1135,47 @@ int classify_open(int argc, char** argv) return 0; } +pp::foundation::Status parse_simulate_app_session_args( + int argc, + char** argv, + SimulateAppSessionArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--unsaved") { + args.unsaved = true; + } else if (key == "--close-prompt-open") { + args.close_prompt_open = true; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int simulate_app_session(int argc, char** argv) +{ + SimulateAppSessionArgs args; + const auto status = parse_simulate_app_session_args(argc, argv, args); + if (!status.ok()) { + print_error("simulate-app-session", status.message); + return 2; + } + + const auto open_decision = pp::app::plan_project_open(args.unsaved); + const auto close_decision = pp::app::plan_close_request(args.unsaved, args.close_prompt_open); + std::cout << "{\"ok\":true,\"command\":\"simulate-app-session\"" + << ",\"state\":{\"unsaved\":" << json_bool(args.unsaved) + << ",\"closePromptOpen\":" << json_bool(args.close_prompt_open) + << "},\"decisions\":{\"projectOpen\":\"" + << project_open_decision_name(open_decision) + << "\",\"closeRequest\":\"" + << close_request_decision_name(close_decision) + << "\"}}\n"; + return 0; +} + int inspect_project(int argc, char** argv) { InspectProjectArgs args; @@ -3038,6 +3112,10 @@ int main(int argc, char** argv) return simulate_document_history(argc, argv); } + if (command == "simulate-app-session") { + return simulate_app_session(argc, argv); + } + if (command == "simulate-image-import") { return simulate_image_import(argc, argv); }