diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 1dabcbf..a04a923 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -1,7 +1,7 @@ # Modernization Debt Log Status: live -Last updated: 2026-05-31 +Last updated: 2026-06-01 Every shortcut, temporary adapter, retained vendored dependency, skipped platform gate, compatibility shim, or incomplete automation path must be @@ -28,7 +28,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0007 | Open | Modernization | `vcpkg.json` exists but CMake is not yet using a validated vcpkg toolchain on this machine | `vcpkg` is not available on PATH and Visual Studio reports manifest mode is disabled | `cmake --preset windows-msvc-default` currently configures with vendored dependencies | Add validated vcpkg toolchain/preset integration for desktop, Android, and Apple triplets | | DEBT-0008 | Open | Modernization | `windows-msvc-default` preset is used for local validation because the VS 2026 generator is not installed here | The target VS 2026 preset must remain, but this machine configures with Visual Studio 17 2022 | `cmake --preset windows-msvc-default`; `ctest --preset desktop-fast --build-config Debug` | Validate `windows-vs2026-x64` on a machine with Visual Studio 2026 installed and make it the default Windows validation preset | | DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, not APK/package variants | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands | -| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document model but is not yet wired to legacy `Canvas`, PPI load/save, selection masks, or undo/redo | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade | +| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model but is not yet wired to legacy `Canvas`, PPI load/save, selection masks, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade | | DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Package-smoke covers Windows AppX, Android APK variants, Apple bundles, and WebGL output where local toolchains are present | | DEBT-0012 | Open | Modernization | `pp_vendor_tinyxml2` compiles the retained vendored tinyxml2 copy for `pp_ui_core` layout parsing | vcpkg is not validated yet, but layout parsing needs a structured XML parser now | `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Replace with vcpkg tinyxml2 target once desktop and mobile triplets are validated | | DEBT-0013 | Open | Modernization | `pp_assets` and `pano_cli inspect-project` recognize only the fixed PPI header, not thumbnail bytes or the project body | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_ppi_header_tests` | Full PPI load/save fixtures cover thumbnail, layers, frames, metadata, corrupt payloads, and round-trip compatibility | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 21d55ad..648a8d9 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -309,8 +309,8 @@ PNG/JPEG signature detection plus PPI header recognition, with corrupt/truncated/unsupported tests. `pp_paint` has started with CPU reference math for the five current shader blend modes. `pp_document` has started with a pure canvas/layer/frame model -and layer/frame invariant tests. `pp_renderer_api` has started with renderer-neutral -texture/readback descriptors and validation tests. `pp_paint_renderer` has +and layer/frame/undo-redo history invariant tests. `pp_renderer_api` has started +with renderer-neutral texture/readback descriptors and validation tests. `pp_paint_renderer` has started with deterministic CPU layer compositing over renderer extents using the paint blend reference. `pp_ui_core` has started with XML-layout-facing length parsing, tinyxml-backed layout XML parsing, and invalid input tests. diff --git a/src/document/document.cpp b/src/document/document.cpp index 32c7b07..4af2364 100644 --- a/src/document/document.cpp +++ b/src/document/document.cpp @@ -231,4 +231,91 @@ pp::foundation::Status CanvasDocument::set_active_frame(std::size_t index) noexc return pp::foundation::Status::success(); } +pp::foundation::Result DocumentHistory::create( + CanvasDocument initial_document, + std::size_t max_entries) +{ + if (max_entries < min_document_history_entries) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document history must keep at least two entries")); + } + + if (max_entries > max_document_history_entries) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("document history entry limit exceeds the configured limit")); + } + + DocumentHistory history; + history.max_entries_ = max_entries; + history.entries_.reserve(max_entries); + history.entries_.push_back(initial_document); + return pp::foundation::Result::success(history); +} + +const CanvasDocument& DocumentHistory::current() const noexcept +{ + return entries_[current_index_]; +} + +std::size_t DocumentHistory::size() const noexcept +{ + return entries_.size(); +} + +std::size_t DocumentHistory::current_index() const noexcept +{ + return current_index_; +} + +bool DocumentHistory::can_undo() const noexcept +{ + return current_index_ > 0; +} + +bool DocumentHistory::can_redo() const noexcept +{ + return current_index_ + 1U < entries_.size(); +} + +pp::foundation::Status DocumentHistory::apply(CanvasDocument next_document) +{ + if (entries_.empty()) { + return pp::foundation::Status::invalid_argument("document history is not initialized"); + } + + if (can_redo()) { + entries_.erase(entries_.begin() + static_cast(current_index_ + 1U), entries_.end()); + } + + entries_.push_back(next_document); + if (entries_.size() > max_entries_) { + entries_.erase(entries_.begin()); + } else { + ++current_index_; + } + + current_index_ = entries_.size() - 1U; + return pp::foundation::Status::success(); +} + +pp::foundation::Status DocumentHistory::undo() noexcept +{ + if (!can_undo()) { + return pp::foundation::Status::out_of_range("document history has no undo entry"); + } + + --current_index_; + return pp::foundation::Status::success(); +} + +pp::foundation::Status DocumentHistory::redo() noexcept +{ + if (!can_redo()) { + return pp::foundation::Status::out_of_range("document history has no redo entry"); + } + + ++current_index_; + return pp::foundation::Status::success(); +} + } diff --git a/src/document/document.h b/src/document/document.h index 510d537..f0d2364 100644 --- a/src/document/document.h +++ b/src/document/document.h @@ -15,6 +15,8 @@ constexpr std::uint32_t max_canvas_dimension = 131072; constexpr std::uint32_t max_layer_count = 1024; constexpr std::uint32_t max_frame_count = 100000; constexpr std::uint32_t min_frame_duration_ms = 1; +constexpr std::size_t min_document_history_entries = 2; +constexpr std::size_t max_document_history_entries = 10000; struct DocumentConfig { std::uint32_t width = 0; @@ -64,4 +66,26 @@ private: std::vector frames_; }; +class DocumentHistory { +public: + [[nodiscard]] static pp::foundation::Result create( + CanvasDocument initial_document, + std::size_t max_entries = 256); + + [[nodiscard]] const CanvasDocument& current() const noexcept; + [[nodiscard]] std::size_t size() const noexcept; + [[nodiscard]] std::size_t current_index() const noexcept; + [[nodiscard]] bool can_undo() const noexcept; + [[nodiscard]] bool can_redo() const noexcept; + + [[nodiscard]] pp::foundation::Status apply(CanvasDocument next_document); + [[nodiscard]] pp::foundation::Status undo() noexcept; + [[nodiscard]] pp::foundation::Status redo() noexcept; + +private: + std::size_t max_entries_ = 0; + std::size_t current_index_ = 0; + std::vector entries_; +}; + } diff --git a/tests/document/document_tests.cpp b/tests/document/document_tests.cpp index 634f4c4..a8ead15 100644 --- a/tests/document/document_tests.cpp +++ b/tests/document/document_tests.cpp @@ -4,7 +4,9 @@ #include using pp::document::CanvasDocument; +using pp::document::DocumentHistory; using pp::document::DocumentConfig; +using pp::document::max_document_history_entries; using pp::document::max_canvas_dimension; using pp::document::max_frame_count; using pp::document::max_layer_count; @@ -145,6 +147,117 @@ void rejects_invalid_animation_frame_operations(pp::tests::Harness& h) PP_EXPECT(h, max_frame_count > document.frames().size()); } +void records_document_history_and_restores_snapshots(pp::tests::Harness& h) +{ + auto document_result = CanvasDocument::create( + DocumentConfig { .width = 64, .height = 64, .layer_count = 1 }); + PP_EXPECT(h, document_result.ok()); + + auto history_result = DocumentHistory::create(document_result.value(), 4); + PP_EXPECT(h, history_result.ok()); + auto history = history_result.value(); + + auto with_layer = history.current(); + const auto added_layer = with_layer.add_layer("Paint"); + PP_EXPECT(h, added_layer.ok()); + PP_EXPECT(h, history.apply(with_layer).ok()); + + auto with_frame = history.current(); + const auto added_frame = with_frame.add_frame(250); + PP_EXPECT(h, added_frame.ok()); + PP_EXPECT(h, history.apply(with_frame).ok()); + + PP_EXPECT(h, history.size() == 3U); + PP_EXPECT(h, history.current_index() == 2U); + PP_EXPECT(h, history.current().layers().size() == 2U); + PP_EXPECT(h, history.current().frames().size() == 2U); + PP_EXPECT(h, history.can_undo()); + PP_EXPECT(h, !history.can_redo()); + + PP_EXPECT(h, history.undo().ok()); + PP_EXPECT(h, history.current().layers().size() == 2U); + PP_EXPECT(h, history.current().frames().size() == 1U); + PP_EXPECT(h, history.can_redo()); + + PP_EXPECT(h, history.undo().ok()); + PP_EXPECT(h, history.current().layers().size() == 1U); + PP_EXPECT(h, history.current().frames().size() == 1U); + const auto undo_past_start = history.undo(); + PP_EXPECT(h, !undo_past_start.ok()); + PP_EXPECT(h, undo_past_start.code == StatusCode::out_of_range); + + PP_EXPECT(h, history.redo().ok()); + PP_EXPECT(h, history.current().layers().size() == 2U); +} + +void applying_after_undo_discards_redo_branch(pp::tests::Harness& h) +{ + auto document_result = CanvasDocument::create( + DocumentConfig { .width = 64, .height = 64, .layer_count = 1 }); + PP_EXPECT(h, document_result.ok()); + + auto history_result = DocumentHistory::create(document_result.value(), 5); + PP_EXPECT(h, history_result.ok()); + auto history = history_result.value(); + + auto first_branch = history.current(); + PP_EXPECT(h, first_branch.add_layer("Branch A").ok()); + PP_EXPECT(h, history.apply(first_branch).ok()); + + auto second_branch = history.current(); + PP_EXPECT(h, second_branch.add_layer("Branch B").ok()); + PP_EXPECT(h, history.apply(second_branch).ok()); + + PP_EXPECT(h, history.undo().ok()); + PP_EXPECT(h, history.can_redo()); + + auto replacement_branch = history.current(); + PP_EXPECT(h, replacement_branch.add_layer("Replacement").ok()); + PP_EXPECT(h, history.apply(replacement_branch).ok()); + + PP_EXPECT(h, !history.can_redo()); + PP_EXPECT(h, history.current().layers().size() == 3U); + PP_EXPECT(h, history.current().layers()[2].name == std::string_view("Replacement")); +} + +void bounds_document_history_capacity(pp::tests::Harness& h) +{ + auto document_result = CanvasDocument::create( + DocumentConfig { .width = 64, .height = 64, .layer_count = 1 }); + PP_EXPECT(h, document_result.ok()); + + auto too_small = DocumentHistory::create(document_result.value(), 1); + auto too_large = DocumentHistory::create(document_result.value(), max_document_history_entries + 1U); + PP_EXPECT(h, !too_small.ok()); + PP_EXPECT(h, too_small.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !too_large.ok()); + PP_EXPECT(h, too_large.status().code == StatusCode::out_of_range); + + auto history_result = DocumentHistory::create(document_result.value(), 3); + PP_EXPECT(h, history_result.ok()); + auto history = history_result.value(); + + for (std::uint32_t i = 0; i < 5U; ++i) { + auto next = history.current(); + const auto added = next.add_frame(100U + i); + PP_EXPECT(h, added.ok()); + PP_EXPECT(h, history.apply(next).ok()); + PP_EXPECT(h, history.size() <= 3U); + } + + PP_EXPECT(h, history.size() == 3U); + PP_EXPECT(h, history.current_index() == 2U); + PP_EXPECT(h, history.current().frames().size() == 6U); + + PP_EXPECT(h, history.undo().ok()); + PP_EXPECT(h, history.current().frames().size() == 5U); + PP_EXPECT(h, history.undo().ok()); + PP_EXPECT(h, history.current().frames().size() == 4U); + const auto undo_evicted_entry = history.undo(); + PP_EXPECT(h, !undo_evicted_entry.ok()); + PP_EXPECT(h, undo_evicted_entry.code == StatusCode::out_of_range); +} + } int main() @@ -156,5 +269,8 @@ int main() harness.run("moves_layers_and_preserves_active_layer_identity", moves_layers_and_preserves_active_layer_identity); harness.run("manages_animation_frames_and_duration", manages_animation_frames_and_duration); harness.run("rejects_invalid_animation_frame_operations", rejects_invalid_animation_frame_operations); + harness.run("records_document_history_and_restores_snapshots", records_document_history_and_restores_snapshots); + harness.run("applying_after_undo_discards_redo_branch", applying_after_undo_discards_redo_branch); + harness.run("bounds_document_history_capacity", bounds_document_history_capacity); return harness.finish(); }