diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index da6e4fb..219d5c6 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -215,6 +215,11 @@ Known local toolchain state: operations. It keeps those live paths on the `pp_app_core` contracts while legacy `Canvas`, `NodeLayer`, `NodePanelLayer`, and `ActionManager` execution remain tracked by `DEBT-0021` and `DEBT-0032`. +- `NodePanelLayer::update_attributes()` now consumes the tested + `pp_app_core` layer panel view model for current opacity, alpha-lock, blend + mode, and per-layer visibility projection. `pano_cli plan-layer-panel-view` + exposes the same view model for automation; retained canvas mutation, UI node + ownership, and undo behavior remain tracked by `DEBT-0021`. - `src/legacy_app_shell_services.*` is the current app-shell bridge for File menu routing, export-menu routing, main-toolbar commands, About menu commands, and direct Tools menu commands. It keeps those live paths on the diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index a97f93e..181650d 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -45,6 +45,12 @@ agent or engineer to remove them without reconstructing context from chat. projection now also uses a tested `pp_app_core` view model exposed by `pano_cli plan-animation-panel-view`, including stale-selection behavior. Legacy canvas/layer/UI execution remains open under DEBT-0022. +- 2026-06-05: DEBT-0021 was narrowed again. Layer panel selected-control and + visibility view projection now goes through tested `pp_app_core` planning, + `NodePanelLayer::update_attributes()` consumes that view model in the live + app, and `pano_cli plan-layer-panel-view` exposes the same path for + automation. Legacy layer mutation, UI node ownership, and undo wiring remain + open under DEBT-0021. - 2026-06-04: DEBT-0036 was narrowed again. Canvas stroke commit, thumbnail, and object-draw history paths now query saved blend state through tested `pp_renderer_gl` capability-state dispatch; CanvasLayer equirect @@ -79,7 +85,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0017 | Open | Modernization | Startup storage path preparation, `App::clipboard_get_text`, `App::clipboard_set_text`, `App::show_cursor`, `App::hide_cursor`, `App::showKeyboard`, `App::hideKeyboard`, `App::display_file`, `App::share_file`, native app/window close, UI-thread lifecycle hooks, render-context acquire/release/present hooks, render-target binding hooks, render platform hint hooks, render debug callback hooks, render-capture frame hooks, recording cleanup, live asset/layout reload policy, diagnostic stacktrace/crash hooks, per-frame platform hooks, `App::pick_image`, `App::pick_file`, the non-writer `App::pick_file_save`, `App::pick_dir`, working-directory picker/display-path policy, canvas input tip/pressure policy, prepared-file save/download handoff, work-directory document export collection policy, app network TLS verification policy, PPBR export data-directory policy, SonarPen availability/startup, and VR mode start/stop now call the SDK-free `pp::platform::PlatformServices` interface, and Windows injects `WindowsPlatformServices` from `src/platform_windows/windows_platform_services.*`; Windows render-platform hint and debug-output state token/enable sequencing now delegates to tested `pp_renderer_gl` helpers, leaving Windows with context, callback, console, and Win32 ownership; the retained macOS fallback render-platform hint enable sequence also delegates to the same tested `pp_renderer_gl` helper; non-Windows live implementations still use `src/platform_legacy/legacy_platform_services.*`, a named fallback adapter that forwards to retained Apple/Android/Linux/Web bridge functions and retained no-op branches, including retained iOS canvas tip behavior, retained macOS directory picker/display-path behavior, retained iOS SonarPen bridge, retained non-Windows VR unsupported/no-op behavior, and retained macOS PPBR export directory override; `pp_platform_api` also owns the default network TLS policy helper consumed by retained curl sites that cannot yet depend on injected services | Preserve behavior while moving platform execution behind a testable service boundary before platform shell implementations are injected | `pp_platform_api_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_platform_io_tests`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Replace `src/platform_legacy/legacy_platform_services.*` with injected `pp_platform_*` service implementations owned by each non-Windows platform shell | | DEBT-0019 | Open | Modernization | Unreferenced-parameter warnings are muted globally through `pp_project_warnings` with MSVC `/wd4100` and Clang/GCC `-Wno-unused-parameter` | Legacy callbacks, virtual hooks, serializer methods, and platform/API compatibility functions carry many intentionally unused parameters during the component split; muting this keeps stricter warning builds focused on higher-signal migration issues | `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset linux-clang --target pp_foundation` | Remove `/wd4100` and `-Wno-unused-parameter`, mark intentionally unused parameters with names/comments or `[[maybe_unused]]`, and make the Windows app plus headless Clang/GCC tests pass without unreferenced-parameter warnings | | DEBT-0020 | Open | Modernization | Document resize dialog state, selected-resolution planning, and execution dispatch now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, `pano_cli plan-document-resize`, and the `DocumentResizeServices` boundary, and live resize shares `src/legacy_document_canvas_services.*` with canvas clear commands, but the shared live bridge still calls legacy `Canvas::resize`, updates the legacy app title, and clears legacy `ActionManager` history through the history bridge | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by injected document/app services with no legacy resize adapter, title shim, or direct `ActionManager` history clearing | -| DEBT-0021 | Open | Modernization | Layer rename planning/execution dispatch and layer panel operation planning/execution dispatch now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, `pano_cli plan-layer-operation`, `DocumentLayerRenameServices`, and `DocumentLayerOperationServices`, and the live execution adapters are centralized in `src/legacy_document_layer_services.*`, but that shared bridge still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely | +| DEBT-0021 | Open | Modernization | Layer rename planning/execution dispatch, layer panel operation planning/execution dispatch, and layer panel selected-control/visibility view projection now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `NodePanelLayer::update_attributes()`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-operation`, `pano_cli plan-layer-panel-view`, `DocumentLayerRenameServices`, and `DocumentLayerOperationServices`, and the live execution adapters are centralized in `src/legacy_document_layer_services.*`, but that shared bridge and panel adapter still mutate legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries | Preserve existing UI/canvas behavior while document layer commands, panel projection, and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `pano_cli plan-layer-panel-view --layer-count 3 --current-index 1 --hidden-index 2 --locked-index 1 --current-opacity 0.25 --current-blend-mode 4`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution and panel state projection are owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely | | DEBT-0022 | Open | Modernization | Animation panel frame command planning, panel action planning, panel view-model projection, timeline scrub planning, panel-control/timeline execution dispatch, selected-frame click dispatch, playback tick stepping, and play-mode toggles now consume pure `pp_app_core` through `NodePanelAnimation`, `NodeAnimationTimeline`, `pano_cli plan-animation-operation`, `pano_cli plan-animation-panel-action`, `pano_cli plan-animation-panel-view`, `pano_cli plan-animation-timeline-scrub`, and `DocumentAnimationServices`; live execution is centralized in `src/legacy_document_animation_services.*`, but that bridge still mutates or reads legacy `Canvas`/`Layer` frame state, canvas mode, animation-panel timeline/playback fields, and uses a temporary `NodePanelAnimation` friend adapter | Preserve existing animation panel behavior while timeline/frame commands move toward the document/app command boundary | `pp_app_core_document_animation_tests`; `pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0`; `pano_cli plan-animation-operation --kind select --frame-count 3 --selected-frame 1 --layer-index 2 --layer-id 42`; `pano_cli plan-animation-operation --kind playback --total-duration 5 --current-frame 4 --offset 1`; `pano_cli plan-animation-operation --kind toggle-playback --playing`; `pano_cli plan-animation-panel-action --action next --total-duration 5 --current-frame 4`; `pano_cli plan-animation-panel-view --layer-count 2 --frame-count 3 --total-duration 6 --current-layer 1 --current-frame 4`; `pano_cli plan-animation-timeline-scrub --total-duration 5 --cursor-x 174.99`; `ctest --preset desktop-fast --build-config Debug` | Animation frame/timeline/playback execution is owned by injected document/app timeline services with no legacy `Canvas`/`Layer`/canvas-mode adapter and UI nodes acting only as adapters or removed entirely | | DEBT-0023 | Open | Modernization | Brush/color/preset/stroke-settings UI planning, texture-list add/remove/reorder planning, brush preset-list add/select/move/remove/clear planning, stroke-panel slider/toggle/blend/reset planning, and execution dispatch now consume pure `pp_app_core` through `App::init_sidebar`, `NodePanelBrush`, `NodePanelBrushPreset`, `NodePanelStroke`, restored/docked floating-panel callbacks, `pano_cli plan-brush-operation`, `pano_cli plan-brush-texture-list`, `pano_cli plan-brush-preset-list`, `pano_cli plan-brush-stroke-control`, `BrushUiServices`, `BrushTextureListServices`, `BrushPresetListServices`, and `BrushStrokeControlServices`, and live execution is centralized in `src/legacy_brush_ui_services.*` or narrow legacy service bridges where possible, but preset-list execution still mutates legacy `NodePanelBrushPreset` child nodes directly while the bridge still mutates legacy `Brush`/`Canvas::I`, loads/saves legacy brush texture images, refreshes legacy quick/stroke/color widgets, and uses temporary `NodePanelBrush`/`NodePanelBrushPreset` friend adapters to reach private list state | Preserve existing brush UI behavior while brush commands move toward a brush/app/asset command boundary and asset-managed texture/preset selection | `pp_app_core_brush_ui_tests`; `pano_cli plan-brush-operation --kind color --r 0.25 --g 0.5 --b 0.75 --a 1`; `pano_cli plan-brush-operation --kind pattern --path data/patterns/noise.png --thumb data/patterns/thumbs/noise.png`; `pano_cli plan-brush-texture-list --kind add --dir brushes --data-path data --source C:/Temp/soft.png`; `pano_cli plan-brush-preset-list --kind remove --item-count 1 --current-index 0`; `pano_cli plan-brush-stroke-control --kind float --setting tip-size --value 42.5`; `pano_cli plan-brush-stroke-control --kind blend --setting pattern --blend-mode 3`; `ctest --preset desktop-fast --build-config Debug` | Brush color/texture/preset/stroke-settings, texture-list, preset-list, and stroke-control execution are owned by injected brush/app/asset/UI services with no legacy brush/canvas adapter, direct `NodePanelBrushPreset` child mutation, or brush-panel friend access | | DEBT-0024 | Open | Modernization | Grid/heightmap/lightmap UI planning and execution dispatch now consume pure `pp_app_core` through `NodePanelGrid`, `pano_cli plan-grid-operation`, and the `GridUiServices` boundary; live execution is centralized in `src/legacy_grid_ui_services.*`, and retained CPU lightmap row dispatch now uses shared `parallel_for` instead of platform-specific Win32/Apple worker APIs, but the bridge still performs legacy image loading, OpenGL texture updates, nanort lightmap baking/progress, and `Canvas::draw_objects` commit execution | Preserve grid/lightmap behavior while moving renderable grid commands toward app/renderer/document boundaries | `pp_app_core_grid_ui_tests`; `pano_cli plan-grid-operation --kind render --float32 --texture-resolution 1024 --samples 32`; `ctest --preset desktop-fast --build-config Debug` | Grid heightmap/lightmap execution is owned by app/renderer/document services with `NodePanelGrid` acting only as UI adapter | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index c9a4d4d..369a300 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -515,6 +515,10 @@ duplicate, select, reorder, remove, opacity, visibility, alpha-lock, blend-mode, and highlight actions used by the live layer panel. Direct layer-panel operations now dispatch through `DocumentLayerOperationServices` before the shared app-shell layer bridge continues legacy `Canvas` and UI layer execution. +`pano_cli plan-layer-panel-view` exposes the app-core layer panel view model for +current opacity, alpha-lock, blend mode, and per-layer visibility state, and +live `NodePanelLayer::update_attributes()` now consumes that tested projection +before writing the retained legacy UI controls. `pano_cli plan-layer-menu` exposes app-core planning for Layer menu clear, rename, and merge-down labels/actions, and direct Layer menu commands now dispatch through `DocumentLayerMenuServices` before the legacy canvas/layer UI diff --git a/src/app_core/document_layer.h b/src/app_core/document_layer.h index 00e02fa..ee5296d 100644 --- a/src/app_core/document_layer.h +++ b/src/app_core/document_layer.h @@ -8,6 +8,7 @@ #include #include #include +#include namespace pp::app { @@ -84,6 +85,33 @@ struct DocumentLayerMergePlan { bool create_history = true; }; +struct DocumentLayerPanelInput { + int layer_index = 0; + std::string name; + float opacity = 1.0F; + bool visible = true; + bool alpha_locked = false; + int blend_mode = 0; +}; + +struct DocumentLayerPanelLayerView { + int layer_index = 0; + std::string name; + float opacity = 1.0F; + bool visible = true; + bool alpha_locked = false; + int blend_mode = 0; + bool current = false; +}; + +struct DocumentLayerPanelView { + int current_index = 0; + float current_opacity = 1.0F; + bool current_alpha_locked = false; + int current_blend_mode = 0; + std::vector layers; +}; + class DocumentLayerMenuServices { public: virtual ~DocumentLayerMenuServices() = default; @@ -158,6 +186,60 @@ public: return pp::foundation::Status::success(); } +[[nodiscard]] inline pp::foundation::Result plan_document_layer_panel_view( + const std::vector& layers, + int current_index) +{ + if (layers.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("layer panel requires at least one layer")); + } + + const auto current_status = validate_layer_index(static_cast(layers.size()), current_index); + if (!current_status.ok()) { + return pp::foundation::Result::failure(current_status); + } + + DocumentLayerPanelView view; + view.current_index = current_index; + view.layers.reserve(layers.size()); + + for (const auto& input : layers) { + const auto index_status = validate_layer_index(static_cast(layers.size()), input.layer_index); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + if (!std::isfinite(input.opacity) || input.opacity < 0.0F || input.opacity > 1.0F) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1")); + } + + if (input.blend_mode < 0 || input.blend_mode >= document_layer_legacy_blend_mode_count) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("layer blend mode is outside the supported range")); + } + + const bool current = input.layer_index == current_index; + DocumentLayerPanelLayerView layer; + layer.layer_index = input.layer_index; + layer.name = input.name; + layer.opacity = input.opacity; + layer.visible = input.visible; + layer.alpha_locked = input.alpha_locked; + layer.blend_mode = input.blend_mode; + layer.current = current; + if (current) { + view.current_opacity = input.opacity; + view.current_alpha_locked = input.alpha_locked; + view.current_blend_mode = input.blend_mode; + } + view.layers.push_back(std::move(layer)); + } + + return pp::foundation::Result::success(std::move(view)); +} + [[nodiscard]] inline pp::foundation::Result plan_document_layer_rename( std::string_view old_name, std::string_view requested_name) diff --git a/src/node_panel_layer.cpp b/src/node_panel_layer.cpp index 040f32e..70236ad 100644 --- a/src/node_panel_layer.cpp +++ b/src/node_panel_layer.cpp @@ -1,4 +1,5 @@ #include "pch.h" +#include "app_core/document_layer.h" #include "log.h" #include "node_panel_layer.h" #include "canvas.h" @@ -367,13 +368,38 @@ void NodePanelLayer::clear() void NodePanelLayer::update_attributes() { - auto& l = Canvas::I->m_layers[Canvas::I->m_current_layer_idx]; - m_opacity->set_value(l->m_opacity); - m_alpha_lock->set_value(l->m_alpha_locked); - m_blend_mode->set_index(l->m_blend_mode); - for (int i = 0; i < Canvas::I->m_layers.size(); i++) + if (!Canvas::I) + return; + + std::vector layer_inputs; + layer_inputs.reserve(Canvas::I->m_layers.size()); + for (int i = 0; i < static_cast(Canvas::I->m_layers.size()); i++) { - m_layers[i]->m_visibility->set_value(Canvas::I->m_layers[i]->m_visible); + const auto& layer = Canvas::I->m_layers[i]; + layer_inputs.push_back(pp::app::DocumentLayerPanelInput { + .layer_index = i, + .name = layer->m_name, + .opacity = layer->m_opacity, + .visible = layer->m_visible, + .alpha_locked = layer->m_alpha_locked, + .blend_mode = layer->m_blend_mode, + }); + } + + const auto view = pp::app::plan_document_layer_panel_view(layer_inputs, Canvas::I->m_current_layer_idx); + if (!view) + { + LOG("Layer panel view failed: %s", view.status().message); + return; + } + + m_opacity->set_value(view.value().current_opacity); + m_alpha_lock->set_value(view.value().current_alpha_locked); + m_blend_mode->set_index(view.value().current_blend_mode); + for (const auto& layer : view.value().layers) + { + if (layer.layer_index >= 0 && layer.layer_index < static_cast(m_layers.size())) + m_layers[layer.layer_index]->m_visibility->set_value(layer.visible); } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f71f14a..b5e4f7a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1172,6 +1172,24 @@ if(TARGET pano_cli) LABELS "app;document;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_layer_panel_view_smoke + COMMAND pano_cli plan-layer-panel-view --layer-count 3 --current-index 1 --hidden-index 2 --locked-index 1 --current-opacity 0.25 --current-blend-mode 4) + set_tests_properties(pano_cli_plan_layer_panel_view_smoke PROPERTIES + LABELS "app;document;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-panel-view\".*\"layers\":3.*\"currentIndex\":1.*\"currentName\":\"Layer 1\".*\"currentOpacity\":0.25.*\"currentAlphaLocked\":true.*\"currentBlendMode\":4.*\"visibleLayers\":2.*\"lockedLayers\":1") + + add_test(NAME pano_cli_plan_layer_panel_view_rejects_bad_opacity + COMMAND pano_cli plan-layer-panel-view --layer-count 2 --current-index 1 --current-opacity 1.5) + set_tests_properties(pano_cli_plan_layer_panel_view_rejects_bad_opacity PROPERTIES + LABELS "app;document;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + + add_test(NAME pano_cli_plan_layer_panel_view_rejects_bad_current + COMMAND pano_cli plan-layer-panel-view --layer-count 2 --current-index 2) + set_tests_properties(pano_cli_plan_layer_panel_view_rejects_bad_current PROPERTIES + LABELS "app;document;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_layer_merge_smoke COMMAND pano_cli plan-layer-merge --layer-count 3 --from-index 2 --to-index 1) set_tests_properties(pano_cli_plan_layer_merge_smoke PROPERTIES diff --git a/tests/app_core/document_layer_tests.cpp b/tests/app_core/document_layer_tests.cpp index ab7c2ee..84220d4 100644 --- a/tests/app_core/document_layer_tests.cpp +++ b/tests/app_core/document_layer_tests.cpp @@ -3,6 +3,7 @@ #include #include +#include namespace { @@ -383,6 +384,96 @@ void layer_highlight_is_transient(pp::tests::Harness& harness) PP_EXPECT(harness, !pp::app::plan_document_layer_highlight(2, 2, true)); } +void layer_panel_view_projects_current_controls_and_visibility(pp::tests::Harness& harness) +{ + const std::vector layers { + pp::app::DocumentLayerPanelInput { + .layer_index = 0, + .name = "Base", + .opacity = 1.0F, + .visible = true, + .alpha_locked = false, + .blend_mode = 0, + }, + pp::app::DocumentLayerPanelInput { + .layer_index = 1, + .name = "Ink", + .opacity = 0.25F, + .visible = false, + .alpha_locked = true, + .blend_mode = 4, + }, + }; + + const auto view = pp::app::plan_document_layer_panel_view(layers, 1); + PP_EXPECT(harness, view); + if (view) { + PP_EXPECT(harness, view.value().current_index == 1); + PP_EXPECT(harness, view.value().current_opacity == 0.25F); + PP_EXPECT(harness, view.value().current_alpha_locked); + PP_EXPECT(harness, view.value().current_blend_mode == 4); + PP_EXPECT(harness, view.value().layers.size() == 2); + PP_EXPECT(harness, !view.value().layers[0].current); + PP_EXPECT(harness, view.value().layers[1].current); + PP_EXPECT(harness, view.value().layers[0].name == "Base"); + PP_EXPECT(harness, view.value().layers[1].name == "Ink"); + PP_EXPECT(harness, view.value().layers[0].visible); + PP_EXPECT(harness, !view.value().layers[1].visible); + PP_EXPECT(harness, view.value().layers[1].alpha_locked); + } +} + +void layer_panel_view_rejects_invalid_document_state(pp::tests::Harness& harness) +{ + const std::vector valid_layers { + pp::app::DocumentLayerPanelInput { + .layer_index = 0, + .name = "Base", + .opacity = 1.0F, + .visible = true, + .alpha_locked = false, + .blend_mode = 0, + }, + }; + const std::vector bad_index { + pp::app::DocumentLayerPanelInput { + .layer_index = 2, + .name = "Base", + .opacity = 1.0F, + .visible = true, + .alpha_locked = false, + .blend_mode = 0, + }, + }; + const std::vector bad_opacity { + pp::app::DocumentLayerPanelInput { + .layer_index = 0, + .name = "Base", + .opacity = std::nanf(""), + .visible = true, + .alpha_locked = false, + .blend_mode = 0, + }, + }; + const std::vector bad_blend { + pp::app::DocumentLayerPanelInput { + .layer_index = 0, + .name = "Base", + .opacity = 1.0F, + .visible = true, + .alpha_locked = false, + .blend_mode = pp::app::document_layer_legacy_blend_mode_count, + }, + }; + + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view({}, 0)); + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view(valid_layers, -1)); + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view(valid_layers, 1)); + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view(bad_index, 0)); + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view(bad_opacity, 0)); + PP_EXPECT(harness, !pp::app::plan_document_layer_panel_view(bad_blend, 0)); +} + void layer_operation_executor_dispatches_document_mutations(pp::tests::Harness& harness) { FakeDocumentLayerOperationServices services; @@ -814,6 +905,8 @@ int main() harness.run("layer remove keeps at least one layer", layer_remove_keeps_at_least_one_layer); harness.run("layer metadata plans validate values", layer_metadata_plans_validate_values); harness.run("layer highlight is transient", layer_highlight_is_transient); + harness.run("layer panel view projects current controls and visibility", layer_panel_view_projects_current_controls_and_visibility); + harness.run("layer panel view rejects invalid document state", layer_panel_view_rejects_invalid_document_state); harness.run("layer operation executor dispatches document mutations", layer_operation_executor_dispatches_document_mutations); harness.run("layer operation executor dispatches selection and metadata", layer_operation_executor_dispatches_selection_and_metadata); harness.run("layer operation executor preserves no op and transient actions", layer_operation_executor_preserves_no_op_and_transient_actions); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 4280cf5..3658b29 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -315,6 +315,15 @@ struct PlanLayerMenuArgs { std::string lower_name = "Layer 0"; }; +struct PlanLayerPanelViewArgs { + int layer_count = 2; + int current_index = 0; + int hidden_index = -1; + int locked_index = -1; + float current_opacity = 1.0F; + int current_blend_mode = 0; +}; + struct PlanLayerMergeArgs { int layer_count = 2; int from_index = 1; @@ -1937,6 +1946,7 @@ void print_help() << " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n" << " plan-layer-rename --old-name NAME --new-name NAME\n" << " plan-layer-menu --command clear|rename|merge [--no-current-layer] [--current-index N] [--animation-duration N] [--current-name NAME] [--lower-name NAME]\n" + << " plan-layer-panel-view [--layer-count N] [--current-index N] [--hidden-index N] [--locked-index N] [--current-opacity N] [--current-blend-mode N]\n" << " plan-layer-merge [--layer-count N] [--from-index N] [--to-index N] [--animation-duration N] [--no-history]\n" << " plan-layer-operation --kind add|duplicate|select|reorder|remove|opacity|visibility|alpha-lock|blend-mode|highlight [--layer-count N] [--index N] [--from-index N] [--to-index N] [--source-index N] [--name NAME] [--opacity N] [--blend-mode N] [--enabled]\n" << " plan-animation-operation --kind add|duplicate|remove|duration|move|select|goto|next|prev|playback|toggle-playback|onion [--frame-count N] [--total-duration N] [--current-frame N] [--selected-frame N] [--layer-index N] [--layer-id N] [--current-duration N] [--delta N] [--offset N] [--onion-size N] [--playing]\n" @@ -4380,6 +4390,119 @@ int plan_layer_menu(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_layer_panel_view_args( + int argc, + char** argv, + PlanLayerPanelViewArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--layer-count" || key == "--current-index" || key == "--hidden-index" + || key == "--locked-index" || key == "--current-blend-mode") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_i32_arg(argv[++i]); + if (!value) { + return value.status(); + } + if (key == "--layer-count") { + args.layer_count = value.value(); + } else if (key == "--current-index") { + args.current_index = value.value(); + } else if (key == "--hidden-index") { + args.hidden_index = value.value(); + } else if (key == "--locked-index") { + args.locked_index = value.value(); + } else { + args.current_blend_mode = value.value(); + } + } else if (key == "--current-opacity") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_float_arg(argv[++i]); + if (!value) { + return value.status(); + } + args.current_opacity = value.value(); + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int plan_layer_panel_view(int argc, char** argv) +{ + PlanLayerPanelViewArgs args; + const auto status = parse_plan_layer_panel_view_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-layer-panel-view", status.message); + return 2; + } + + std::vector layers; + if (args.layer_count > 0) { + layers.reserve(static_cast(args.layer_count)); + } + for (int i = 0; i < args.layer_count; ++i) { + layers.push_back(pp::app::DocumentLayerPanelInput { + .layer_index = i, + .name = "Layer " + std::to_string(i), + .opacity = i == args.current_index ? args.current_opacity : 1.0F, + .visible = i != args.hidden_index, + .alpha_locked = i == args.locked_index, + .blend_mode = i == args.current_index ? args.current_blend_mode : 0, + }); + } + + const auto view = pp::app::plan_document_layer_panel_view(layers, args.current_index); + if (!view) { + print_error("plan-layer-panel-view", view.status().message); + return 2; + } + + int visible_count = 0; + int locked_count = 0; + for (const auto& layer : view.value().layers) { + if (layer.visible) { + visible_count += 1; + } + if (layer.alpha_locked) { + locked_count += 1; + } + } + + const auto current_layer = std::find_if( + view.value().layers.begin(), + view.value().layers.end(), + [](const pp::app::DocumentLayerPanelLayerView& layer) { return layer.current; }); + if (current_layer == view.value().layers.end()) { + print_error("plan-layer-panel-view", "layer panel view did not include the current layer"); + return 2; + } + + std::cout << "{\"ok\":true,\"command\":\"plan-layer-panel-view\"" + << ",\"state\":{\"layerCount\":" << args.layer_count + << ",\"currentIndex\":" << args.current_index + << ",\"hiddenIndex\":" << args.hidden_index + << ",\"lockedIndex\":" << args.locked_index + << ",\"currentOpacity\":" << args.current_opacity + << ",\"currentBlendMode\":" << args.current_blend_mode + << "},\"view\":{\"layers\":" << view.value().layers.size() + << ",\"currentIndex\":" << view.value().current_index + << ",\"currentName\":\"" << json_escape(current_layer->name) + << "\",\"currentOpacity\":" << view.value().current_opacity + << ",\"currentAlphaLocked\":" << json_bool(view.value().current_alpha_locked) + << ",\"currentBlendMode\":" << view.value().current_blend_mode + << ",\"visibleLayers\":" << visible_count + << ",\"lockedLayers\":" << locked_count + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_layer_merge_args( int argc, char** argv, @@ -9170,6 +9293,10 @@ int main(int argc, char** argv) return plan_layer_menu(argc, argv); } + if (command == "plan-layer-panel-view") { + return plan_layer_panel_view(argc, argv); + } + if (command == "plan-layer-merge") { return plan_layer_merge(argc, argv); }