diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index ad3bba1..80e59ad 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -1,7 +1,7 @@ # Build And Platform Inventory Status: live -Last updated: 2026-06-04 +Last updated: 2026-06-05 This inventory records the known build surfaces during the CMake migration. Keep it updated as platform paths move to shared CMake targets. @@ -245,9 +245,12 @@ Known local toolchain state: canvas toolbar tool selection, NodeCanvas stylus/input mode switching, and canvas hotkey/touch execution. It keeps those live paths on the `pp_app_core` contracts while canvas mode tip visibility and pressure remapping now ask - `PlatformServices`; legacy `Canvas` mode state, transform actions, picking, - touch-lock, save/UI/cursor calls, brush-size controls, and history execution - remain tracked by `DEBT-0027`. + `PlatformServices`; `NodeCanvas::update_cursor()` now consumes + `pp_app_core` canvas cursor visibility planning exposed through + `pano_cli plan-canvas-cursor` before retained platform cursor dispatch. + Legacy `Canvas` mode state, transform actions, picking, touch-lock, + save/UI/cursor calls, brush-size controls, and history execution remain + tracked by `DEBT-0027`. - `src/legacy_document_animation_services.*` is the current UI-shell bridge for animation frame commands, timeline/selected-frame execution, playback ticks, onion-size updates, and play-mode toggles. It keeps those live paths on the diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 05857d0..ec0d981 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -68,6 +68,13 @@ agent or engineer to remove them without reconstructing context from chat. `pano_cli plan-quick-slider-preview` exposes the path for automation. Legacy quick widgets, brush previews, popup state, and direct `CanvasMode*` field writes remain open under DEBT-0025. +- 2026-06-05: DEBT-0027 was narrowed. Canvas cursor visibility policy now goes + through tested `pp_app_core`, live `NodeCanvas::update_cursor()` consumes the + planner before retained platform cursor dispatch, and + `pano_cli plan-canvas-cursor` exposes draw/erase versus non-paint mode, + small-brush, not-painting, modifier, and malformed-brush states for + automation. Legacy `Canvas`/`CanvasModePen` state reads and app cursor + execution remain open under DEBT-0027. - 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 @@ -108,7 +115,7 @@ agent or engineer to remove them without reconstructing context from chat. | 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 | | DEBT-0025 | Open | Modernization | Quick brush/color slot, mini-state, and size/flow slider preview planning now consume pure `pp_app_core` through `NodePanelQuick`, `pano_cli plan-quick-operation`, `pano_cli plan-quick-slider-preview`, and the `QuickUiServices` boundary; live slot/popup/restore/reset execution is centralized in `src/legacy_quick_ui_services.*`, and live slider callbacks now consume `pp_app_core` preview cursor/tip planning directly, but the bridge and panel adapter still mutate legacy quick UI widgets, `Brush` previews, color picker popup state, preset popup state, and direct legacy `CanvasModePen`/`CanvasModeLine` fields | Preserve quick-panel behavior while quick brush/color and slider preview commands move toward a brush/app command boundary with safer automation coverage | `pp_app_core_quick_ui_tests`; `pano_cli plan-quick-operation --kind brush --current-index 0 --slot-index 2`; `pano_cli plan-quick-operation --kind restore --brush-index 2 --color-index 1 --fire-event`; `pano_cli plan-quick-slider-preview --slider-x 10 --slider-y 20 --slider-height 40 --zoom 2 --pen-mode --no-line-mode`; `ctest --preset desktop-fast --build-config Debug` | Quick-panel selection, popup, restore, reset, brush preview, color execution, and slider preview mode updates are owned by injected app/brush/UI/canvas services with no legacy quick-panel adapter, popup adapter, direct brush-preview mutation, or direct `CanvasMode*` field writes | | DEBT-0026 | Open | Modernization | Toolbar history command planning and canvas hotkey history dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `NodeCanvas`, `pano_cli plan-history-operation`, and the `HistoryUiServices` boundary, and both live callers share `src/legacy_history_services.*` for saturated legacy history metrics and execution, but the shared live bridge still mutates legacy `ActionManager` stacks directly | Preserve undo/redo/clear behavior while moving action history toward document/app command services | `pp_app_core_history_ui_tests`; `pano_cli plan-history-operation --kind undo --undo-count 2`; `pano_cli plan-history-operation --kind clear --undo-count 2 --redo-count 1 --memory-bytes 4096`; `ctest --preset desktop-fast --build-config Debug` | Undo/redo/clear execution is owned by injected document/app history services with no legacy `ActionManager` adapter | -| DEBT-0027 | Open | Modernization | Canvas draw-tool toolbar command, canvas input mode switching, active-state planning/execution dispatch, and canvas keyboard/touch command planning now consume pure `pp_app_core` through `App::init_toolbar_draw`, `App::update`, `NodeCanvas`, `pano_cli plan-canvas-tool`, `pano_cli plan-canvas-tool-state`, `pano_cli plan-canvas-hotkey`, `CanvasToolServices`, and `CanvasHotkeyServices`, live toolbar/input/hotkey execution is centralized in `src/legacy_canvas_tool_services.*`, and canvas mode tip visibility plus pressure remapping now route through `PlatformServices`, but the bridge still mutates or reads legacy `Canvas` mode state, pen picking state, touch-lock state, transform copy/cut action objects, `ActionManager`, legacy save UI, legacy stroke size controls, and cursor/UI singletons | Preserve current toolbar, stylus eraser, keyboard, and touch command behavior while canvas input/tools move toward an app/document command boundary | `pp_app_core_canvas_tool_ui_tests`; `pp_app_core_canvas_hotkey_tests`; `pp_platform_api_tests`; `pano_cli plan-canvas-tool --kind copy`; `pano_cli plan-canvas-tool-state --mode draw --picking --touch-lock`; `pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2`; `pano_cli plan-canvas-hotkey --event key-up --key s --ctrl --shift`; `ctest --preset desktop-fast --build-config Debug` | Canvas tool selection, toolbar state refresh, picking, touch lock, stylus eraser/key mode switching, hotkey/touch command dispatch, save hotkeys, history hotkeys, brush-size hotkeys, and transform action execution are owned by injected app/document/canvas services with no legacy toolbar/canvas adapter | +| DEBT-0027 | Open | Modernization | Canvas draw-tool toolbar command, canvas input mode switching, active-state planning/execution dispatch, canvas cursor visibility planning, and canvas keyboard/touch command planning now consume pure `pp_app_core` through `App::init_toolbar_draw`, `App::update`, `NodeCanvas`, `pano_cli plan-canvas-tool`, `pano_cli plan-canvas-tool-state`, `pano_cli plan-canvas-cursor`, `pano_cli plan-canvas-hotkey`, `CanvasToolServices`, and `CanvasHotkeyServices`, live toolbar/input/hotkey execution is centralized in `src/legacy_canvas_tool_services.*`, `NodeCanvas::update_cursor()` consumes the cursor planner before retained platform cursor dispatch, and canvas mode tip visibility plus pressure remapping now route through `PlatformServices`, but the bridge still mutates or reads legacy `Canvas` mode state, `CanvasModePen` drawing/resizing/picking state, touch-lock state, transform copy/cut action objects, `ActionManager`, legacy save UI, legacy stroke size controls, and cursor/UI singletons | Preserve current toolbar, stylus eraser, cursor visibility, keyboard, and touch command behavior while canvas input/tools move toward an app/document command boundary | `pp_app_core_canvas_tool_ui_tests`; `pp_app_core_canvas_hotkey_tests`; `pp_platform_api_tests`; `pano_cli plan-canvas-tool --kind copy`; `pano_cli plan-canvas-tool-state --mode draw --picking --touch-lock`; `pano_cli plan-canvas-cursor --mode draw --visibility small-brush --brush-size 9.5`; `pano_cli plan-canvas-cursor --visibility small-brush --bad-size`; `pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2`; `pano_cli plan-canvas-hotkey --event key-up --key s --ctrl --shift`; `ctest --preset desktop-fast --build-config Debug` | Canvas tool selection, toolbar state refresh, picking, touch lock, stylus eraser/key mode switching, cursor visibility planning/execution, hotkey/touch command dispatch, save hotkeys, history hotkeys, brush-size hotkeys, and transform action execution are owned by injected app/document/canvas services with no legacy toolbar/canvas adapter | | DEBT-0028 | Open | Modernization | Canvas clear command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, Layer menu clear, `pano_cli plan-canvas-clear`, and the `DocumentCanvasClearServices` boundary, and toolbar/Layer-menu clear share `src/legacy_document_canvas_services.*`, but the shared live bridge still calls legacy `Canvas::clear`, which records `ActionLayerClear`, clears the current layer/frame, and marks legacy `Canvas::I` unsaved | Preserve clear-current-layer behavior while canvas/document commands move toward document/app command services | `pp_app_core_document_canvas_tests`; `pano_cli plan-canvas-clear --r 0 --g 0.1 --b 0.2 --a 0.3`; `pano_cli plan-canvas-clear --no-canvas`; `pano_cli plan-layer-menu --command clear --current-index 1 --current-name Paint`; `ctest --preset desktop-fast --build-config Debug` | Canvas clear execution, undo recording, dirty-state updates, and clear color handling are owned by injected document/app services with no legacy canvas-clear adapter | | DEBT-0029 | Open | Modernization | Image import route planning and execution dispatch now consume pure `pp_app_core` through the File menu, `pano_cli plan-image-import`, and the `DocumentImageImportServices` boundary, and live File-menu import execution is centralized in `src/legacy_app_shell_services.*`, but the bridge still loads images with legacy `Image`, calls legacy `Canvas::import_equirectangular`, or configures legacy import transform mode directly | Preserve current File > Import behavior while image import moves toward document/app/asset command services | `pp_app_core_document_import_tests`; `pano_cli plan-image-import --width 4096 --height 2048`; `pano_cli plan-image-import --width 1024 --height 1024`; `ctest --preset desktop-fast --build-config Debug` | Image loading, equirectangular import, transform-placement import, and failure reporting are owned by injected document/app/asset services with File-menu callbacks acting only as adapters and no legacy image-import adapter | | DEBT-0030 | Open | Modernization | File export menu action planning and execution dispatch now consume pure `pp_app_core` through the File menu, `pano_cli plan-export-menu`, and the `DocumentExportMenuServices` boundary, and live execution is centralized in `src/legacy_app_shell_services.*`, but the bridge still opens legacy export dialogs and then reaches legacy canvas/render/video export code | Preserve current export menu behavior while export command execution moves toward document/app/renderer/video services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind png`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-menu --kind layers --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Export menu routing, license gating, target creation, image/layer/cube/depth/animation/timelapse execution, and error reporting are owned by injected document/app/renderer/video services with File-menu callbacks acting only as UI adapters and no legacy export adapter | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 8ed15ad..dcab289 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -579,7 +579,11 @@ touch command handling now consumes `pp_app_core` canvas-hotkey planning for E draw/erase, Ctrl+Z, Ctrl+Shift+Z, Ctrl+S, Ctrl+Shift+S, Tab UI toggle, brush-size brackets, Android back, Alt cursor reveal, and two-finger undo before the shared bridge delegates to legacy -UI/canvas/history adapters. +UI/canvas/history adapters. `pano_cli plan-canvas-cursor` exposes the +canvas-specific cursor visibility policy for draw/erase versus non-paint +modes, small-brush thresholds, active-stroke hiding, and modifier/tool forced +visibility; live `NodeCanvas::update_cursor()` consumes that planner before +retained `App::show_cursor`/`App::hide_cursor` platform dispatch. `pano_cli plan-canvas-clear` exposes app-core planning for the main toolbar clear-current-layer command, including clear color validation, no-canvas handling, undo recording intent, and dirty-state intent; live toolbar execution diff --git a/src/app_core/canvas_tool_ui.h b/src/app_core/canvas_tool_ui.h index a9e1a52..702d8e8 100644 --- a/src/app_core/canvas_tool_ui.h +++ b/src/app_core/canvas_tool_ui.h @@ -2,6 +2,8 @@ #include "foundation/result.h" +#include + namespace pp::app { enum class CanvasToolOperation { @@ -30,6 +32,13 @@ enum class CanvasToolTransformAction { cut, }; +enum class CanvasCursorVisibilityMode { + never, + small_brush, + not_painting, + always, +}; + struct CanvasToolPlan { CanvasToolOperation operation = CanvasToolOperation::select_mode; CanvasToolMode mode = CanvasToolMode::draw; @@ -59,6 +68,25 @@ struct CanvasToolButtonState { bool flood_fill_active = false; }; +struct CanvasCursorVisibilityInput { + CanvasToolMode mode = CanvasToolMode::draw; + CanvasCursorVisibilityMode visibility_mode = CanvasCursorVisibilityMode::never; + bool has_current_brush = true; + float brush_tip_size = 0.0F; + bool pen_is_drawing = false; + bool alt_down = false; + bool pen_is_resizing = false; + bool pen_is_picking = false; +}; + +struct CanvasCursorVisibilityPlan { + bool visible = true; + bool paint_mode = false; + bool uses_brush_size = false; + bool uses_pen_state = false; + bool forced_visible_by_modifier_or_tool = false; +}; + class CanvasToolServices { public: virtual ~CanvasToolServices() = default; @@ -134,6 +162,54 @@ public: return state; } +[[nodiscard]] inline constexpr bool canvas_tool_mode_is_paint(CanvasToolMode mode) noexcept +{ + return mode == CanvasToolMode::draw || mode == CanvasToolMode::erase; +} + +[[nodiscard]] inline pp::foundation::Result plan_canvas_cursor_visibility( + const CanvasCursorVisibilityInput& input) +{ + CanvasCursorVisibilityPlan plan; + plan.paint_mode = canvas_tool_mode_is_paint(input.mode); + if (!plan.paint_mode) { + plan.visible = true; + return pp::foundation::Result::success(plan); + } + + switch (input.visibility_mode) { + case CanvasCursorVisibilityMode::always: + plan.visible = true; + break; + case CanvasCursorVisibilityMode::never: + plan.visible = false; + break; + case CanvasCursorVisibilityMode::small_brush: + if (!input.has_current_brush) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("canvas cursor small-brush mode requires a current brush")); + } + if (!std::isfinite(input.brush_tip_size) || input.brush_tip_size < 0.0F) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("canvas cursor brush size must be finite and non-negative")); + } + plan.visible = input.brush_tip_size < 10.0F; + plan.uses_brush_size = true; + break; + case CanvasCursorVisibilityMode::not_painting: + plan.visible = !input.pen_is_drawing; + plan.uses_pen_state = true; + break; + } + + if (input.alt_down || input.pen_is_resizing || input.pen_is_picking) { + plan.visible = true; + plan.forced_visible_by_modifier_or_tool = true; + } + + return pp::foundation::Result::success(plan); +} + [[nodiscard]] inline pp::foundation::Status execute_canvas_tool_plan( const CanvasToolPlan& plan, CanvasToolServices& services) diff --git a/src/node_canvas.cpp b/src/node_canvas.cpp index 970b013..7187cc7 100644 --- a/src/node_canvas.cpp +++ b/src/node_canvas.cpp @@ -258,6 +258,54 @@ pp::app::CanvasHotkeyKey canvas_hotkey_key(kKey key) noexcept } } +pp::app::CanvasToolMode canvas_tool_mode(kCanvasMode mode) noexcept +{ + switch (mode) { + case kCanvasMode::Draw: + return pp::app::CanvasToolMode::draw; + case kCanvasMode::Erase: + return pp::app::CanvasToolMode::erase; + case kCanvasMode::Line: + return pp::app::CanvasToolMode::line; + case kCanvasMode::Camera: + return pp::app::CanvasToolMode::camera; + case kCanvasMode::Grid: + return pp::app::CanvasToolMode::grid; + case kCanvasMode::Copy: + return pp::app::CanvasToolMode::copy; + case kCanvasMode::Cut: + return pp::app::CanvasToolMode::cut; + case kCanvasMode::Fill: + return pp::app::CanvasToolMode::fill; + case kCanvasMode::MaskFree: + return pp::app::CanvasToolMode::mask_free; + case kCanvasMode::MaskLine: + return pp::app::CanvasToolMode::mask_line; + case kCanvasMode::FloodFill: + return pp::app::CanvasToolMode::flood_fill; + case kCanvasMode::COUNT: + return pp::app::CanvasToolMode::draw; + } + + return pp::app::CanvasToolMode::draw; +} + +pp::app::CanvasCursorVisibilityMode canvas_cursor_visibility_mode(NodeCanvas::kCursorVisibility mode) noexcept +{ + switch (mode) { + case NodeCanvas::kCursorVisibility::Never: + return pp::app::CanvasCursorVisibilityMode::never; + case NodeCanvas::kCursorVisibility::SmallBrush: + return pp::app::CanvasCursorVisibilityMode::small_brush; + case NodeCanvas::kCursorVisibility::NotPainting: + return pp::app::CanvasCursorVisibilityMode::not_painting; + case NodeCanvas::kCursorVisibility::Always: + return pp::app::CanvasCursorVisibilityMode::always; + } + + return pp::app::CanvasCursorVisibilityMode::never; +} + pp::app::CanvasHotkeyState canvas_hotkey_state(bool mouse_focused, int touch_finger_count = 0) noexcept { pp::app::CanvasHotkeyState state; @@ -961,24 +1009,24 @@ void NodeCanvas::set_cursor_visibility(kCursorVisibility mode) void NodeCanvas::update_cursor() { - bool visible = true; - if (m_canvas->m_current_mode == kCanvasMode::Draw || - m_canvas->m_current_mode == kCanvasMode::Erase) - { - if (m_cursor_visibility == kCursorVisibility::Always) - visible = true; - if (m_cursor_visibility == kCursorVisibility::Never) - visible = false; - if (m_cursor_visibility == kCursorVisibility::SmallBrush) - visible = m_canvas->m_current_brush->m_tip_size < 10; - if (m_cursor_visibility == kCursorVisibility::NotPainting) - visible = !m_canvas->get_mode()->m_drawing; - if (App::I->keys[(int)kKey::KeyAlt] || - m_canvas->get_mode()->m_resizing || - m_canvas->get_mode()->m_picking) - visible = true; + auto* pen_mode = m_canvas->get_mode(); + const auto plan = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = canvas_tool_mode(m_canvas->m_current_mode), + .visibility_mode = canvas_cursor_visibility_mode(m_cursor_visibility), + .has_current_brush = m_canvas->m_current_brush != nullptr, + .brush_tip_size = m_canvas->m_current_brush ? m_canvas->m_current_brush->m_tip_size : 0.0F, + .pen_is_drawing = pen_mode && pen_mode->m_drawing, + .alt_down = App::I && App::I->keys[(int)kKey::KeyAlt], + .pen_is_resizing = pen_mode && pen_mode->m_resizing, + .pen_is_picking = pen_mode && pen_mode->m_picking, + }); + if (!plan) { + LOG("Canvas cursor visibility planning failed: %s", plan.status().message); + App::I->show_cursor(); + return; } - visible ? App::I->show_cursor() : App::I->hide_cursor(); + + plan.value().visible ? App::I->show_cursor() : App::I->hide_cursor(); } void NodeCanvas::on_tick(float dt) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 799a7eb..096a022 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1628,6 +1628,42 @@ if(TARGET pano_cli) LABELS "app;ui;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_canvas_cursor_small_brush_smoke + COMMAND pano_cli plan-canvas-cursor --mode draw --visibility small-brush --brush-size 9.5) + set_tests_properties(pano_cli_plan_canvas_cursor_small_brush_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-cursor\".*\"mode\":\"draw\".*\"visibility\":\"small-brush\".*\"visible\":true.*\"paintMode\":true.*\"usesBrushSize\":true") + + add_test(NAME pano_cli_plan_canvas_cursor_not_painting_hidden_smoke + COMMAND pano_cli plan-canvas-cursor --mode erase --visibility not-painting --drawing) + set_tests_properties(pano_cli_plan_canvas_cursor_not_painting_hidden_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-cursor\".*\"mode\":\"erase\".*\"visibility\":\"not-painting\".*\"drawing\":true.*\"visible\":false.*\"usesPenState\":true") + + add_test(NAME pano_cli_plan_canvas_cursor_alt_forces_visible_smoke + COMMAND pano_cli plan-canvas-cursor --mode draw --visibility never --alt) + set_tests_properties(pano_cli_plan_canvas_cursor_alt_forces_visible_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-cursor\".*\"visibility\":\"never\".*\"visible\":true.*\"forcedVisibleByModifierOrTool\":true") + + add_test(NAME pano_cli_plan_canvas_cursor_non_paint_smoke + COMMAND pano_cli plan-canvas-cursor --mode camera --visibility never) + set_tests_properties(pano_cli_plan_canvas_cursor_non_paint_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-cursor\".*\"mode\":\"camera\".*\"visible\":true.*\"paintMode\":false") + + add_test(NAME pano_cli_plan_canvas_cursor_rejects_missing_brush + COMMAND pano_cli plan-canvas-cursor --visibility small-brush --no-brush) + set_tests_properties(pano_cli_plan_canvas_cursor_rejects_missing_brush PROPERTIES + LABELS "app;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + + add_test(NAME pano_cli_plan_canvas_cursor_rejects_bad_size + COMMAND pano_cli plan-canvas-cursor --visibility small-brush --bad-size) + set_tests_properties(pano_cli_plan_canvas_cursor_rejects_bad_size PROPERTIES + LABELS "app;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_grid_operation_pick_smoke COMMAND pano_cli plan-grid-operation --kind pick) set_tests_properties(pano_cli_plan_grid_operation_pick_smoke PROPERTIES diff --git a/tests/app_core/canvas_tool_ui_tests.cpp b/tests/app_core/canvas_tool_ui_tests.cpp index 99e40e6..efb8ab7 100644 --- a/tests/app_core/canvas_tool_ui_tests.cpp +++ b/tests/app_core/canvas_tool_ui_tests.cpp @@ -1,6 +1,7 @@ #include "app_core/canvas_tool_ui.h" #include "test_harness.h" +#include #include namespace { @@ -128,6 +129,96 @@ void button_state_tracks_active_mode_and_toggles(pp::tests::Harness& harness) PP_EXPECT(harness, !bucket.mask_line_active); } +void cursor_visibility_projects_canvas_state(pp::tests::Harness& harness) +{ + const auto non_paint = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::camera, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::never, + }); + PP_EXPECT(harness, non_paint); + if (non_paint) { + PP_EXPECT(harness, non_paint.value().visible); + PP_EXPECT(harness, !non_paint.value().paint_mode); + } + + const auto small = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::draw, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::small_brush, + .has_current_brush = true, + .brush_tip_size = 9.5F, + }); + PP_EXPECT(harness, small); + if (small) { + PP_EXPECT(harness, small.value().visible); + PP_EXPECT(harness, small.value().paint_mode); + PP_EXPECT(harness, small.value().uses_brush_size); + } + + const auto large = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::erase, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::small_brush, + .has_current_brush = true, + .brush_tip_size = 10.0F, + }); + PP_EXPECT(harness, large); + if (large) { + PP_EXPECT(harness, !large.value().visible); + PP_EXPECT(harness, large.value().uses_brush_size); + } + + const auto painting = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::draw, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::not_painting, + .pen_is_drawing = true, + }); + PP_EXPECT(harness, painting); + if (painting) { + PP_EXPECT(harness, !painting.value().visible); + PP_EXPECT(harness, painting.value().uses_pen_state); + } +} + +void cursor_visibility_forces_visible_for_modifier_or_tool(pp::tests::Harness& harness) +{ + const auto alt = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::draw, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::never, + .alt_down = true, + }); + PP_EXPECT(harness, alt); + if (alt) { + PP_EXPECT(harness, alt.value().visible); + PP_EXPECT(harness, alt.value().forced_visible_by_modifier_or_tool); + } + + const auto picking = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::erase, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::not_painting, + .pen_is_drawing = true, + .pen_is_picking = true, + }); + PP_EXPECT(harness, picking); + if (picking) { + PP_EXPECT(harness, picking.value().visible); + PP_EXPECT(harness, picking.value().forced_visible_by_modifier_or_tool); + } +} + +void cursor_visibility_rejects_invalid_brush_state(pp::tests::Harness& harness) +{ + PP_EXPECT(harness, !pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::draw, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::small_brush, + .has_current_brush = false, + })); + PP_EXPECT(harness, !pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = pp::app::CanvasToolMode::draw, + .visibility_mode = pp::app::CanvasCursorVisibilityMode::small_brush, + .has_current_brush = true, + .brush_tip_size = std::nanf(""), + })); +} + void executor_dispatches_tool_actions(pp::tests::Harness& harness) { FakeCanvasToolServices services; @@ -205,6 +296,9 @@ int main() harness.run("transform tools plan copy and cut actions", transform_tools_plan_copy_and_cut_actions); harness.run("pick and touch lock toggle state", pick_and_touch_lock_toggle_state); harness.run("button state tracks active mode and toggles", button_state_tracks_active_mode_and_toggles); + harness.run("cursor visibility projects canvas state", cursor_visibility_projects_canvas_state); + harness.run("cursor visibility forces visible for modifier or tool", cursor_visibility_forces_visible_for_modifier_or_tool); + harness.run("cursor visibility rejects invalid brush state", cursor_visibility_rejects_invalid_brush_state); harness.run("executor dispatches tool actions", executor_dispatches_tool_actions); harness.run("executor no-ops pick when not in draw mode", executor_no_ops_pick_when_not_in_draw_mode); harness.run("executor rejects malformed plans", executor_rejects_malformed_plans); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index e2659a0..77cce79 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -515,6 +515,18 @@ struct PlanCanvasToolStateArgs { bool touch_lock = false; }; +struct PlanCanvasCursorArgs { + std::string mode = "draw"; + std::string visibility = "small-brush"; + bool has_brush = true; + float brush_size = 9.5F; + bool drawing = false; + bool alt = false; + bool resizing = false; + bool picking = false; + bool bad_size = false; +}; + struct PlanQuickOperationArgs { std::string kind = "brush"; int current_index = 0; @@ -1456,6 +1468,22 @@ const char* canvas_tool_transform_action_name(pp::app::CanvasToolTransformAction return "none"; } +const char* canvas_cursor_visibility_mode_name(pp::app::CanvasCursorVisibilityMode mode) noexcept +{ + switch (mode) { + case pp::app::CanvasCursorVisibilityMode::never: + return "never"; + case pp::app::CanvasCursorVisibilityMode::small_brush: + return "small-brush"; + case pp::app::CanvasCursorVisibilityMode::not_painting: + return "not-painting"; + case pp::app::CanvasCursorVisibilityMode::always: + return "always"; + } + + return "never"; +} + const char* canvas_hotkey_event_name(pp::app::CanvasHotkeyEvent event) noexcept { switch (event) { @@ -2003,6 +2031,7 @@ void print_help() << " plan-canvas-hotkey --event key-down|key-up|touch-tap --key e|z|s|tab|alt|android-back|bracket-left|bracket-right [--ctrl] [--shift] [--mouse-focus] [--undo-count N] [--redo-count N] [--touch-fingers N]\n" << " plan-canvas-tool --kind draw|erase|line|camera|grid|copy|cut|fill|mask-free|mask-line|bucket|pick|touch-lock [--current-mode-draw]\n" << " plan-canvas-tool-state [--mode draw|erase|line|camera|grid|copy|cut|fill|mask-free|mask-line|bucket] [--picking] [--touch-lock]\n" + << " plan-canvas-cursor [--mode draw|erase|line|camera|grid|copy|cut|fill|mask-free|mask-line|bucket] [--visibility never|small-brush|not-painting|always] [--brush-size N] [--no-brush] [--drawing] [--alt] [--resizing] [--picking] [--bad-size]\n" << " plan-grid-operation --kind pick|load|reload|clear|render|commit [--path FILE] [--no-heightmap] [--no-canvas] [--float32] [--float16] [--texture-resolution N] [--samples N]\n" << " plan-history-operation --kind undo|redo|clear [--undo-count N] [--redo-count N] [--memory-bytes N]\n" << " plan-main-toolbar --command open|save|undo|redo|clear-history|clear-canvas|message-box|settings [--undo-count N] [--redo-count N] [--memory-bytes N] [--no-canvas]\n" @@ -6436,6 +6465,30 @@ pp::foundation::Result parse_canvas_tool_mode(std::stri pp::foundation::Status::invalid_argument("unknown canvas tool mode")); } +pp::foundation::Result parse_canvas_cursor_visibility_mode( + std::string_view mode) +{ + if (mode == "never") { + return pp::foundation::Result::success( + pp::app::CanvasCursorVisibilityMode::never); + } + if (mode == "small-brush") { + return pp::foundation::Result::success( + pp::app::CanvasCursorVisibilityMode::small_brush); + } + if (mode == "not-painting") { + return pp::foundation::Result::success( + pp::app::CanvasCursorVisibilityMode::not_painting); + } + if (mode == "always") { + return pp::foundation::Result::success( + pp::app::CanvasCursorVisibilityMode::always); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown canvas cursor visibility mode")); +} + int plan_canvas_tool(int argc, char** argv) { PlanCanvasToolArgs args; @@ -6693,6 +6746,105 @@ int plan_canvas_tool_state(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_canvas_cursor_args( + int argc, + char** argv, + PlanCanvasCursorArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--mode" || key == "--visibility") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + if (key == "--mode") { + args.mode = argv[++i]; + } else { + args.visibility = argv[++i]; + } + } else if (key == "--brush-size") { + 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.brush_size = value.value(); + } else if (key == "--no-brush") { + args.has_brush = false; + } else if (key == "--drawing") { + args.drawing = true; + } else if (key == "--alt") { + args.alt = true; + } else if (key == "--resizing") { + args.resizing = true; + } else if (key == "--picking") { + args.picking = true; + } else if (key == "--bad-size") { + args.bad_size = true; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int plan_canvas_cursor(int argc, char** argv) +{ + PlanCanvasCursorArgs args; + const auto status = parse_plan_canvas_cursor_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-canvas-cursor", status.message); + return 2; + } + + const auto mode = parse_canvas_tool_mode(args.mode); + if (!mode) { + print_error("plan-canvas-cursor", mode.status().message); + return 2; + } + const auto visibility = parse_canvas_cursor_visibility_mode(args.visibility); + if (!visibility) { + print_error("plan-canvas-cursor", visibility.status().message); + return 2; + } + + const auto plan = pp::app::plan_canvas_cursor_visibility(pp::app::CanvasCursorVisibilityInput { + .mode = mode.value(), + .visibility_mode = visibility.value(), + .has_current_brush = args.has_brush, + .brush_tip_size = args.bad_size ? std::nanf("") : args.brush_size, + .pen_is_drawing = args.drawing, + .alt_down = args.alt, + .pen_is_resizing = args.resizing, + .pen_is_picking = args.picking, + }); + if (!plan) { + print_error("plan-canvas-cursor", plan.status().message); + return 2; + } + + const auto& value = plan.value(); + std::cout << "{\"ok\":true,\"command\":\"plan-canvas-cursor\"" + << ",\"state\":{\"mode\":\"" << canvas_tool_mode_name(mode.value()) + << "\",\"visibility\":\"" << canvas_cursor_visibility_mode_name(visibility.value()) + << "\",\"hasBrush\":" << json_bool(args.has_brush) + << ",\"brushSize\":" << args.brush_size + << ",\"drawing\":" << json_bool(args.drawing) + << ",\"alt\":" << json_bool(args.alt) + << ",\"resizing\":" << json_bool(args.resizing) + << ",\"picking\":" << json_bool(args.picking) + << "},\"plan\":{\"visible\":" << json_bool(value.visible) + << ",\"paintMode\":" << json_bool(value.paint_mode) + << ",\"usesBrushSize\":" << json_bool(value.uses_brush_size) + << ",\"usesPenState\":" << json_bool(value.uses_pen_state) + << ",\"forcedVisibleByModifierOrTool\":" << json_bool(value.forced_visible_by_modifier_or_tool) + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_grid_operation_args( int argc, char** argv, @@ -9753,6 +9905,10 @@ int main(int argc, char** argv) return plan_canvas_tool_state(argc, argv); } + if (command == "plan-canvas-cursor") { + return plan_canvas_cursor(argc, argv); + } + if (command == "plan-grid-operation") { return plan_grid_operation(argc, argv); }