From 0e03e5940a5058eb8d193d9bb66a90146ec26ee2 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 22:10:50 +0200 Subject: [PATCH] Add app document route core --- CMakeLists.txt | 13 +++++ docs/modernization/build-inventory.md | 9 +++- docs/modernization/capability-map.md | 1 + docs/modernization/debt.md | 2 +- docs/modernization/roadmap.md | 8 ++- src/app.cpp | 15 +++--- src/app_core/document_route.cpp | 67 +++++++++++++++++++++++++ src/app_core/document_route.h | 27 ++++++++++ tests/CMakeLists.txt | 10 ++++ tests/app_core/document_route_tests.cpp | 64 +++++++++++++++++++++++ 10 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/app_core/document_route.cpp create mode 100644 src/app_core/document_route.h create mode 100644 tests/app_core/document_route_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 26cdbff..02e8ff1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -210,6 +210,18 @@ target_link_libraries(pp_ui_core pp_xml_tinyxml2 pp_project_warnings) +add_library(pp_app_core STATIC + src/app_core/document_route.cpp) +target_include_directories(pp_app_core + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(pp_app_core + PUBLIC + pp_foundation + pp_project_options + PRIVATE + pp_project_warnings) + if(PP_BUILD_TOOLS) add_subdirectory(tools/pano_cli) endif() @@ -461,6 +473,7 @@ if(PP_BUILD_APP) "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(panopainter_app PUBLIC + pp_app_core pp_legacy_app pp_panopainter_ui pp_project_options diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 8b57a6d..b0d20c3 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`, 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 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 | @@ -391,6 +391,10 @@ Known local toolchain state: - `pano_cli apply-stroke-script` exposes file-driven stroke-script application to a pure document face payload and writes a PPI artifact for inspect/load round-trip automation. +- `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_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. @@ -425,6 +429,9 @@ Known warnings after the current CMake app build: header and static-analysis warnings while it still depends on `pp_legacy_app`; these should be reduced as the UI core/app UI boundary is tightened instead of suppressed globally. +- `pp_app_core` is the first pure app-engine target consumed by + `panopainter_app`; it should grow only with UI-free command routing, + validation, and app service contracts that can be tested without a window. - `panopainter_app` currently surfaces existing app orchestration, GLM, base64, VR, and serializer warnings now that app sources live in the composition target; warning cleanup should follow component ownership rather diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index 98eaeca..709043d 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -12,6 +12,7 @@ and validation command. | Capability | Current Area | Target Owner | Required Tests | | --- | --- | --- | --- | | 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`, `pp_panopainter_ui`, `pp_document`, `pp_assets` | Project/ABR/PPBR route tests, malformed path tests, app 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 512e8e1..e22ef8f 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 | Avoid behavior changes while introducing component boundaries | App launch and component tests | Replace singleton reaches with context/service injection at component boundaries | +| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document` now consumes 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`; `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 b68ac2a..28149f8 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -165,8 +165,12 @@ owns retained base `Node`, layout, text, image, input, popup, slider, scroll, and settings UI controls as an object-library boundary folded into the legacy app adapter until those paths are replaced by `pp_ui_core` and app-specific UI targets. -`panopainter_app` is now a real static target that owns app orchestration -sources, app version metadata, and version-header generation. +`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` consumes 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. `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 diff --git a/src/app.cpp b/src/app.cpp index 7b4b2d2..3d173e5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -5,6 +5,7 @@ #include "node_dialog_open.h" #include "node_progress_bar.h" #include "mp4enc.h" +#include "app_core/document_route.h" #include "renderer_gl/opengl_capabilities.h" #ifdef __APPLE__ @@ -187,15 +188,11 @@ void App::create() void App::open_document(std::string path) { - std::regex r(R"((.*)[\\/]([^\\/]+)\.(\w+)$)"); - std::smatch m; - if (!std::regex_search(path, m, r)) + const auto route = pp::app::classify_document_open_path(path); + if (!route) return; - std::string base = m[1].str(); - std::string name = m[2].str(); - std::string ext = m[3].str(); - if (str_iequals(ext, "abr")) + if (route.value().kind == pp::app::DocumentOpenKind::import_abr) { auto mb = message_box("Import ABR", "Would you like to import the brushes?", true); mb->on_submit = [this, path] (Node* target) { @@ -203,7 +200,7 @@ void App::open_document(std::string path) target->destroy(); }; } - else if (str_iequals(ext, "ppbr")) + else if (route.value().kind == pp::app::DocumentOpenKind::import_ppbr) { auto mb = message_box("Import PPBR", "Would you like to import the brushes?", true); mb->on_submit = [this, path] (Node* target) { @@ -213,6 +210,8 @@ void App::open_document(std::string path) } 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; diff --git a/src/app_core/document_route.cpp b/src/app_core/document_route.cpp new file mode 100644 index 0000000..47e30b3 --- /dev/null +++ b/src/app_core/document_route.cpp @@ -0,0 +1,67 @@ +#include "app_core/document_route.h" + +#include +#include + +namespace pp::app { +namespace { + +[[nodiscard]] bool is_extension_char(char value) noexcept +{ + const auto ch = static_cast(value); + return std::isalnum(ch) != 0 || value == '_'; +} + +[[nodiscard]] std::string lowercase_ascii(std::string_view value) +{ + std::string lowered; + lowered.reserve(value.size()); + for (const char ch : value) { + lowered.push_back(static_cast(std::tolower(static_cast(ch)))); + } + return lowered; +} + +} + +pp::foundation::Result classify_document_open_path(std::string_view path) +{ + const auto separator = path.find_last_of("/\\"); + if (separator == std::string_view::npos || separator + 1U >= path.size()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document path must include a directory and file name")); + } + + const auto dot = path.find_last_of('.'); + if (dot == std::string_view::npos || dot <= separator + 1U || dot + 1U >= path.size()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document path must include a file extension")); + } + + const std::string_view extension = path.substr(dot + 1U); + for (const char ch : extension) { + if (!is_extension_char(ch)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document extension contains unsupported characters")); + } + } + + auto lowered_extension = lowercase_ascii(extension); + auto kind = DocumentOpenKind::open_project; + if (lowered_extension == "abr") { + kind = DocumentOpenKind::import_abr; + } else if (lowered_extension == "ppbr") { + kind = DocumentOpenKind::import_ppbr; + } + + return pp::foundation::Result::success( + DocumentOpenRoute { + .kind = kind, + .path = std::string(path), + .directory = std::string(path.substr(0U, separator)), + .name = std::string(path.substr(separator + 1U, dot - separator - 1U)), + .extension = std::move(lowered_extension), + }); +} + +} diff --git a/src/app_core/document_route.h b/src/app_core/document_route.h new file mode 100644 index 0000000..57afa1e --- /dev/null +++ b/src/app_core/document_route.h @@ -0,0 +1,27 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include + +namespace pp::app { + +enum class DocumentOpenKind { + import_abr, + import_ppbr, + open_project, +}; + +struct DocumentOpenRoute { + DocumentOpenKind kind = DocumentOpenKind::open_project; + std::string path; + std::string directory; + std::string name; + std::string extension; +}; + +[[nodiscard]] pp::foundation::Result classify_document_open_path( + std::string_view path); + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3b9fb6c..996e696 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -258,6 +258,16 @@ add_test(NAME pp_ui_core_layout_xml_tests COMMAND pp_ui_core_layout_xml_tests) set_tests_properties(pp_ui_core_layout_xml_tests PROPERTIES LABELS "ui;desktop-fast;fuzz") +add_executable(pp_app_core_document_route_tests + app_core/document_route_tests.cpp) +target_link_libraries(pp_app_core_document_route_tests PRIVATE + pp_app_core + pp_test_harness) + +add_test(NAME pp_app_core_document_route_tests COMMAND pp_app_core_document_route_tests) +set_tests_properties(pp_app_core_document_route_tests PROPERTIES + LABELS "app;desktop-fast;fuzz") + if(TARGET pano_cli) add_test(NAME pano_cli_create_document_smoke COMMAND pano_cli create-document --width 64 --height 32 --layers 2) diff --git a/tests/app_core/document_route_tests.cpp b/tests/app_core/document_route_tests.cpp new file mode 100644 index 0000000..ce0d76e --- /dev/null +++ b/tests/app_core/document_route_tests.cpp @@ -0,0 +1,64 @@ +#include "app_core/document_route.h" +#include "test_harness.h" + +namespace { + +void classifies_regular_project_path(pp::tests::Harness& harness) +{ + const auto route = pp::app::classify_document_open_path("D:\\Paint\\Scenes\\demo.ppi"); + + PP_EXPECT(harness, route); + PP_EXPECT(harness, route.value().kind == pp::app::DocumentOpenKind::open_project); + PP_EXPECT(harness, route.value().path == "D:\\Paint\\Scenes\\demo.ppi"); + PP_EXPECT(harness, route.value().directory == "D:\\Paint\\Scenes"); + PP_EXPECT(harness, route.value().name == "demo"); + PP_EXPECT(harness, route.value().extension == "ppi"); +} + +void classifies_brush_imports_case_insensitively(pp::tests::Harness& harness) +{ + const auto abr_route = pp::app::classify_document_open_path("/brushes/Clouds.ABR"); + const auto ppbr_route = pp::app::classify_document_open_path("/brushes/palette.PpBr"); + + PP_EXPECT(harness, abr_route); + PP_EXPECT(harness, ppbr_route); + PP_EXPECT(harness, abr_route.value().kind == pp::app::DocumentOpenKind::import_abr); + PP_EXPECT(harness, abr_route.value().extension == "abr"); + PP_EXPECT(harness, ppbr_route.value().kind == pp::app::DocumentOpenKind::import_ppbr); + PP_EXPECT(harness, ppbr_route.value().extension == "ppbr"); +} + +void preserves_names_with_inner_dots(pp::tests::Harness& harness) +{ + const auto route = pp::app::classify_document_open_path("C:/projects/shot.001.paint.ppi"); + + PP_EXPECT(harness, route); + PP_EXPECT(harness, route.value().directory == "C:/projects"); + PP_EXPECT(harness, route.value().name == "shot.001.paint"); + PP_EXPECT(harness, route.value().extension == "ppi"); +} + +void rejects_paths_that_legacy_regex_did_not_accept(pp::tests::Harness& harness) +{ + const auto no_directory = pp::app::classify_document_open_path("demo.ppi"); + const auto no_extension = pp::app::classify_document_open_path("D:/Paint/demo"); + const auto empty_file = pp::app::classify_document_open_path("D:/Paint/.ppi"); + const auto unsupported_extension_chars = pp::app::classify_document_open_path("D:/Paint/demo.ppi.tmp!"); + + PP_EXPECT(harness, !no_directory); + PP_EXPECT(harness, !no_extension); + PP_EXPECT(harness, !empty_file); + PP_EXPECT(harness, !unsupported_extension_chars); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("classifies regular project path", classifies_regular_project_path); + harness.run("classifies brush imports case insensitively", classifies_brush_imports_case_insensitively); + harness.run("preserves names with inner dots", preserves_names_with_inner_dots); + harness.run("rejects paths that legacy regex did not accept", rejects_paths_that_legacy_regex_did_not_accept); + return harness.finish(); +}