diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f529f4..9e1cb9c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -227,6 +227,7 @@ add_library(pp_app_core STATIC src/app_core/app_preferences.h src/app_core/app_status.h src/app_core/brush_ui.h + src/app_core/canvas_hotkey.h src/app_core/canvas_tool_ui.h src/app_core/document_animation.h src/app_core/document_canvas.h diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 3fc6dca..85892b0 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -22,7 +22,7 @@ agent or engineer to remove them without reconstructing context from chat. | --- | --- | --- | --- | --- | --- | --- | | DEBT-0001 | Open | Modernization | Existing platform build files remain alongside new CMake | Required for incremental migration without losing platform coverage | Existing platform builds plus new CMake configure | Remove after all platform builds consume shared CMake targets | | DEBT-0002 | Open | Modernization | Vendored SDK and patched libraries retained initially | Some dependencies are SDK-only, patched, or have platform-specific binaries | Dependency inventory and platform build smoke tests | Replace with vcpkg packages or document permanent vendored status after triplet evaluation | -| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display and renderer diagnostic decisions, document resize decisions, layer rename/menu decisions, Tools menu/panel decisions, About menu/diagnostic decisions, main toolbar/status decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, `pano_cli plan-about-menu`, `pano_cli plan-main-toolbar`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-menu`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/share/platform-I/O/display/keyboard/cloud/resize/layer/tools/about/toolbar contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, Tools panel creation/execution, About dialog/diagnostic execution, toolbar/status dialog/history/canvas execution, status/display UI rendering, renderer diagnostic capability adaptation, document resize execution, layer rename/menu execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4 execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_tools_menu_tests`; `pp_app_core_about_menu_tests`; `pp_app_core_main_toolbar_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12 --framebuffer-fetch --float32 --float32-linear --float16`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | +| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, file-menu save actions, `NodeCanvas` canvas hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display and renderer diagnostic decisions, document resize decisions, layer rename/menu decisions, Tools menu/panel decisions, About menu/diagnostic decisions, main toolbar/status decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, `pano_cli plan-about-menu`, `pano_cli plan-main-toolbar`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-menu`, `pano_cli plan-canvas-hotkey`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/share/platform-I/O/display/keyboard/cloud/resize/layer/tools/about/toolbar/canvas-command contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, Tools panel creation/execution, About dialog/diagnostic execution, toolbar/status dialog/history/canvas execution, status/display UI rendering, renderer diagnostic capability adaptation, document resize execution, layer rename/menu execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4 execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_tools_menu_tests`; `pp_app_core_about_menu_tests`; `pp_app_core_main_toolbar_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pp_app_core_canvas_hotkey_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12 --framebuffer-fetch --float32 --float32-linear --float16`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `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 | @@ -44,7 +44,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0024 | Open | Modernization | Grid/heightmap/lightmap UI planning now consumes pure `pp_app_core` through `NodePanelGrid` and `pano_cli plan-grid-operation`, but live execution still performs legacy image loading, OpenGL texture updates, nanort lightmap baking, progress UI, and `Canvas::draw_objects` commit directly | 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 and mini-state planning and execution dispatch now consume pure `pp_app_core` through `NodePanelQuick`, `pano_cli plan-quick-operation`, and the `QuickUiServices` boundary, but the live adapter still mutates legacy quick UI widgets, `Brush` previews, color picker popup state, and preset popup state | Preserve quick-panel behavior while quick brush/color 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`; `ctest --preset desktop-fast --build-config Debug` | Quick-panel selection, popup, restore, reset, brush preview, and color execution are owned by injected app/brush/UI services with no legacy quick-panel adapter | | DEBT-0026 | Open | Modernization | Toolbar history command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `NodeCanvas`, `pano_cli plan-history-operation`, and the `HistoryUiServices` boundary, but the live adapter 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, and active-state planning/execution dispatch 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`, and the `CanvasToolServices` boundary, but live adapters still mutate or read legacy `Canvas` mode state, pen picking state, touch-lock state, and transform copy/cut action objects | Preserve current toolbar, stylus eraser, and keyboard draw/erase behavior while canvas input/tools move toward an app/document command boundary | `pp_app_core_canvas_tool_ui_tests`; `pano_cli plan-canvas-tool --kind copy`; `pano_cli plan-canvas-tool-state --mode draw --picking --touch-lock`; `ctest --preset desktop-fast --build-config Debug` | Canvas tool selection, toolbar state refresh, picking, touch lock, stylus eraser/key mode switching, 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, 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`, but live adapters still mutate or read 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`; `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-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, but the live adapter 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, but the live adapter 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, but the live adapter 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 8c18db6..08eb943 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -535,8 +535,12 @@ before legacy toolbar selection, `Canvas` mode, pen picking, touch-lock, and transform state adapters continue. `pano_cli plan-canvas-tool-state` exposes the matching toolbar active-state refresh used by `App::update` before legacy `Canvas` mode state remains the source of truth. `NodeCanvas` stylus eraser -and `E` key draw/erase mode switching also consume the same app-core executor -before legacy canvas mode execution continues. +mode switching consumes the same app-core executor before legacy canvas mode +execution continues. `NodeCanvas` keyboard and 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 legacy UI/canvas +adapters execute the command. `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 @@ -1333,6 +1337,17 @@ Results: touch-lock toggling, plus toolbar active-state derivation for draw, copy, and bucket modes, service dispatch ordering, pick no-op execution, and malformed execution payload rejection. +- `pp_app_core_canvas_hotkey_tests` passed, covering E draw/erase toggles, + Ctrl+Z/Ctrl+Shift+Z history planning, Ctrl+S/Ctrl+Shift+S document save + intents, Tab UI toggles, brush-size brackets, Android back and two-finger + undo, no-op Ctrl-less Z, bad-count rejection, executor dispatch, and + malformed brush-size execution rejection. +- `pano_cli_plan_canvas_hotkey_ctrl_z_smoke`, + `pano_cli_plan_canvas_hotkey_save_dirty_version_smoke`, + `pano_cli_plan_canvas_hotkey_erase_smoke`, + `pano_cli_plan_canvas_hotkey_two_finger_undo_smoke`, and + `pano_cli_plan_canvas_hotkey_rejects_bad_count` passed and expose live + canvas keyboard/touch command planning as JSON automation. - `pano_cli_plan_canvas_tool_draw_smoke`, `pano_cli_plan_canvas_tool_copy_smoke`, `pano_cli_plan_canvas_tool_pick_noop_smoke`, diff --git a/src/app_core/canvas_hotkey.h b/src/app_core/canvas_hotkey.h new file mode 100644 index 0000000..e3562c6 --- /dev/null +++ b/src/app_core/canvas_hotkey.h @@ -0,0 +1,225 @@ +#pragma once + +#include "app_core/canvas_tool_ui.h" +#include "app_core/document_session.h" +#include "app_core/history_ui.h" +#include "foundation/result.h" + +namespace pp::app { + +enum class CanvasHotkeyEvent { + key_down, + key_up, + touch_tap, +}; + +enum class CanvasHotkeyKey { + other, + android_back, + alt, + e, + s, + tab, + z, + bracket_left, + bracket_right, +}; + +enum class CanvasHotkeyAction { + none, + select_tool, + history, + save_document, + toggle_ui, + adjust_brush_size, + show_cursor, +}; + +struct CanvasHotkeyState { + bool ctrl_down = false; + bool shift_down = false; + bool mouse_focused = false; + int undo_count = 0; + int redo_count = 0; + int touch_finger_count = 0; +}; + +struct CanvasHotkeyPlan { + CanvasHotkeyAction action = CanvasHotkeyAction::none; + CanvasHotkeyEvent event = CanvasHotkeyEvent::key_up; + CanvasHotkeyKey key = CanvasHotkeyKey::other; + CanvasToolPlan tool; + HistoryUiPlan history; + DocumentSaveIntent save_intent = DocumentSaveIntent::save; + float brush_size_delta = 0.0F; + bool no_op = true; +}; + +class CanvasHotkeyServices { +public: + virtual ~CanvasHotkeyServices() = default; + + virtual pp::foundation::Status execute_tool(const CanvasToolPlan& plan) = 0; + virtual pp::foundation::Status execute_history(const HistoryUiPlan& plan) = 0; + virtual void save_document(DocumentSaveIntent intent) = 0; + virtual void toggle_ui() = 0; + virtual void adjust_brush_size(float delta) = 0; + virtual void show_cursor() = 0; +}; + +[[nodiscard]] inline pp::foundation::Status validate_canvas_hotkey_state( + const CanvasHotkeyState& state) noexcept +{ + if (state.undo_count < 0) { + return pp::foundation::Status::out_of_range("undo action count must not be negative"); + } + if (state.redo_count < 0) { + return pp::foundation::Status::out_of_range("redo action count must not be negative"); + } + if (state.touch_finger_count < 0) { + return pp::foundation::Status::out_of_range("touch finger count must not be negative"); + } + return pp::foundation::Status::success(); +} + +[[nodiscard]] inline pp::foundation::Result plan_canvas_hotkey( + CanvasHotkeyEvent event, + CanvasHotkeyKey key, + const CanvasHotkeyState& state) +{ + const auto state_status = validate_canvas_hotkey_state(state); + if (!state_status.ok()) { + return pp::foundation::Result::failure(state_status); + } + + CanvasHotkeyPlan plan; + plan.event = event; + plan.key = key; + + if (event == CanvasHotkeyEvent::touch_tap) { + if (state.touch_finger_count == 2) { + auto history = plan_history_undo(state.undo_count); + if (!history) { + return pp::foundation::Result::failure(history.status()); + } + plan.action = CanvasHotkeyAction::history; + plan.history = history.value(); + plan.no_op = plan.history.no_op; + } + return pp::foundation::Result::success(plan); + } + + if (event == CanvasHotkeyEvent::key_down) { + switch (key) { + case CanvasHotkeyKey::e: + plan.action = CanvasHotkeyAction::select_tool; + plan.tool = plan_canvas_tool_select(CanvasToolMode::erase); + plan.no_op = false; + break; + case CanvasHotkeyKey::android_back: { + auto history = plan_history_undo(state.undo_count); + if (!history) { + return pp::foundation::Result::failure(history.status()); + } + plan.action = CanvasHotkeyAction::history; + plan.history = history.value(); + plan.no_op = plan.history.no_op; + break; + } + case CanvasHotkeyKey::alt: + if (state.mouse_focused) { + plan.action = CanvasHotkeyAction::show_cursor; + plan.no_op = false; + } + break; + default: + break; + } + return pp::foundation::Result::success(plan); + } + + switch (key) { + case CanvasHotkeyKey::e: + plan.action = CanvasHotkeyAction::select_tool; + plan.tool = plan_canvas_tool_select(CanvasToolMode::draw); + plan.no_op = false; + break; + case CanvasHotkeyKey::tab: + plan.action = CanvasHotkeyAction::toggle_ui; + plan.no_op = false; + break; + case CanvasHotkeyKey::z: + if (state.ctrl_down) { + auto history = state.shift_down + ? plan_history_redo(state.redo_count) + : plan_history_undo(state.undo_count); + if (!history) { + return pp::foundation::Result::failure(history.status()); + } + plan.action = CanvasHotkeyAction::history; + plan.history = history.value(); + plan.no_op = plan.history.no_op; + } + break; + case CanvasHotkeyKey::s: + if (state.ctrl_down) { + plan.action = CanvasHotkeyAction::save_document; + plan.save_intent = state.shift_down + ? DocumentSaveIntent::save_dirty_version + : DocumentSaveIntent::save; + plan.no_op = false; + } + break; + case CanvasHotkeyKey::bracket_left: + plan.action = CanvasHotkeyAction::adjust_brush_size; + plan.brush_size_delta = -0.05F; + plan.no_op = false; + break; + case CanvasHotkeyKey::bracket_right: + plan.action = CanvasHotkeyAction::adjust_brush_size; + plan.brush_size_delta = 0.05F; + plan.no_op = false; + break; + default: + break; + } + + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Status execute_canvas_hotkey_plan( + const CanvasHotkeyPlan& plan, + CanvasHotkeyServices& services) +{ + if (plan.no_op || plan.action == CanvasHotkeyAction::none) { + return pp::foundation::Status::success(); + } + + switch (plan.action) { + case CanvasHotkeyAction::select_tool: + return services.execute_tool(plan.tool); + case CanvasHotkeyAction::history: + return services.execute_history(plan.history); + case CanvasHotkeyAction::save_document: + services.save_document(plan.save_intent); + return pp::foundation::Status::success(); + case CanvasHotkeyAction::toggle_ui: + services.toggle_ui(); + return pp::foundation::Status::success(); + case CanvasHotkeyAction::adjust_brush_size: + if (plan.brush_size_delta == 0.0F) { + return pp::foundation::Status::invalid_argument("brush-size hotkey plan must include a delta"); + } + services.adjust_brush_size(plan.brush_size_delta); + return pp::foundation::Status::success(); + case CanvasHotkeyAction::show_cursor: + services.show_cursor(); + return pp::foundation::Status::success(); + case CanvasHotkeyAction::none: + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown canvas hotkey action"); +} + +} // namespace pp::app diff --git a/src/node_canvas.cpp b/src/node_canvas.cpp index 273635b..a0e6a21 100644 --- a/src/node_canvas.cpp +++ b/src/node_canvas.cpp @@ -5,6 +5,7 @@ #include #include +#include "app_core/canvas_hotkey.h" #include "app_core/canvas_tool_ui.h" #include "app_core/history_ui.h" #include "app.h" @@ -69,20 +70,6 @@ pp::paint_renderer::CanvasBlendGatePlan node_canvas_blend_gate_plan( return fallback; } -void run_history_undo_if_available() -{ - const auto plan = pp::app::plan_history_undo(static_cast(ActionManager::I.m_actions.size())); - if (plan && plan.value().invokes_undo) - ActionManager::undo(); -} - -void run_history_redo_if_available() -{ - const auto plan = pp::app::plan_history_redo(static_cast(ActionManager::I.m_redos.size())); - if (plan && plan.value().invokes_redo) - ActionManager::redo(); -} - class LegacyNodeCanvasToolServices final : public pp::app::CanvasToolServices { public: void select_toolbar_button(pp::app::CanvasToolMode) override @@ -116,6 +103,124 @@ public: } }; +class LegacyNodeCanvasHistoryServices final : public pp::app::HistoryUiServices { +public: + void invoke_undo() override + { + ActionManager::undo(); + } + + void invoke_redo() override + { + ActionManager::redo(); + } + + void clear_history() override + { + ActionManager::clear(); + } +}; + +class LegacyNodeCanvasHotkeyServices final : public pp::app::CanvasHotkeyServices { +public: + pp::foundation::Status execute_tool(const pp::app::CanvasToolPlan& plan) override + { + LegacyNodeCanvasToolServices services; + return pp::app::execute_canvas_tool_plan(plan, services); + } + + pp::foundation::Status execute_history(const pp::app::HistoryUiPlan& plan) override + { + LegacyNodeCanvasHistoryServices services; + return pp::app::execute_history_ui_plan(plan, services); + } + + void save_document(pp::app::DocumentSaveIntent intent) override + { + App::I->save_document(intent); + } + + void toggle_ui() override + { + App::I->toggle_ui(); + } + + void adjust_brush_size(float delta) override + { + if (!App::I || !App::I->stroke || !App::I->stroke->m_tip_size) + return; + + const float value = App::I->stroke->m_tip_size->get_value(); + const float next_value = glm::clamp(value + delta, 0.0F, 1.0F); + App::I->stroke->set_size(next_value, true, true); + } + + void show_cursor() override + { + App::I->show_cursor(); + } +}; + +pp::app::CanvasHotkeyKey canvas_hotkey_key(kKey key) noexcept +{ + switch (key) { + case kKey::AndroidBack: + return pp::app::CanvasHotkeyKey::android_back; + case kKey::KeyAlt: + return pp::app::CanvasHotkeyKey::alt; + case kKey::KeyE: + return pp::app::CanvasHotkeyKey::e; + case kKey::KeyS: + return pp::app::CanvasHotkeyKey::s; + case kKey::KeyTab: + return pp::app::CanvasHotkeyKey::tab; + case kKey::KeyZ: + return pp::app::CanvasHotkeyKey::z; + case kKey::KeyBracketLeft: + return pp::app::CanvasHotkeyKey::bracket_left; + case kKey::KeyBracketRight: + return pp::app::CanvasHotkeyKey::bracket_right; + default: + return pp::app::CanvasHotkeyKey::other; + } +} + +pp::app::CanvasHotkeyState canvas_hotkey_state(bool mouse_focused, int touch_finger_count = 0) noexcept +{ + pp::app::CanvasHotkeyState state; + state.ctrl_down = App::I && App::I->keys[(int)kKey::KeyCtrl]; + state.shift_down = App::I && App::I->keys[(int)kKey::KeyShift]; + state.mouse_focused = mouse_focused; + state.undo_count = static_cast(ActionManager::I.m_actions.size()); + state.redo_count = static_cast(ActionManager::I.m_redos.size()); + state.touch_finger_count = touch_finger_count; + return state; +} + +void execute_canvas_hotkey_plan(const pp::app::CanvasHotkeyPlan& plan) +{ + LegacyNodeCanvasHotkeyServices services; + const auto status = pp::app::execute_canvas_hotkey_plan(plan, services); + if (!status.ok()) + LOG("Canvas hotkey action failed: %s", status.message); +} + +void run_canvas_hotkey( + pp::app::CanvasHotkeyEvent event, + kKey key, + bool mouse_focused, + int touch_finger_count = 0) +{ + const auto plan = pp::app::plan_canvas_hotkey( + event, + canvas_hotkey_key(key), + canvas_hotkey_state(mouse_focused, touch_finger_count)); + if (plan) + execute_canvas_hotkey_plan(plan.value()); + else + LOG("Canvas hotkey planning failed: %s", plan.status().message); +} + void run_canvas_tool_mode(pp::app::CanvasToolMode mode) { const auto plan = pp::app::plan_canvas_tool_select(mode); @@ -700,43 +805,19 @@ kEventResult NodeCanvas::handle_event(Event* e) update_cursor(); break; case kEventType::KeyDown: - if (ke->m_key == kKey::KeyE) - run_canvas_tool_mode(pp::app::CanvasToolMode::erase); - if (ke->m_key == kKey::AndroidBack) - run_history_undo_if_available(); - if (ke->m_key == kKey::KeyAlt && m_mouse_focus) - App::I->show_cursor(); + run_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + ke->m_key, + m_mouse_focus); for (auto& mode : *m_canvas->m_mode) mode->on_KeyEvent(ke); break; case kEventType::KeyUp: update_cursor(); - if (ke->m_key == kKey::KeyE) - run_canvas_tool_mode(pp::app::CanvasToolMode::draw); - if (ke->m_key == kKey::KeyTab) - App::I->toggle_ui(); - if (ke->m_key == kKey::KeyZ && App::I->keys[(int)kKey::KeyCtrl]) - App::I->keys[(int)kKey::KeyShift] ? run_history_redo_if_available() : run_history_undo_if_available(); - if (ke->m_key == kKey::KeyS && App::I->keys[(int)kKey::KeyCtrl] && !App::I->keys[(int)kKey::KeyShift]) - { - App::I->save_document(pp::app::DocumentSaveIntent::save); - } - if (ke->m_key == kKey::KeyS && App::I->keys[(int)kKey::KeyCtrl] && App::I->keys[(int)kKey::KeyShift]) - { - App::I->save_document(pp::app::DocumentSaveIntent::save_dirty_version); - } - if (ke->m_key == kKey::KeyBracketLeft) - { - float v = App::I->stroke->m_tip_size->get_value(); - float nv = glm::clamp(v - 0.05, 0, 1); - App::I->stroke->set_size(nv, true, true); - } - if (ke->m_key == kKey::KeyBracketRight) - { - float v = App::I->stroke->m_tip_size->get_value(); - float nv = glm::clamp(v + 0.05, 0, 1); - App::I->stroke->set_size(nv, true, true); - } + run_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + ke->m_key, + m_mouse_focus); for (auto& mode : *m_canvas->m_mode) mode->on_KeyEvent(ke); break; @@ -755,8 +836,11 @@ kEventResult NodeCanvas::handle_event(Event* e) mode->on_GestureEvent(ge); break; case kEventType::TouchTap: - if (te->m_finger_count == 2) - run_history_undo_if_available(); + run_canvas_hotkey( + pp::app::CanvasHotkeyEvent::touch_tap, + kKey::Unknown, + m_mouse_focus, + te->m_finger_count); break; default: return kEventResult::Available; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f32a51c..917c703 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -298,6 +298,16 @@ add_test(NAME pp_app_core_canvas_tool_ui_tests COMMAND pp_app_core_canvas_tool_u set_tests_properties(pp_app_core_canvas_tool_ui_tests PROPERTIES LABELS "app;ui;desktop-fast;fuzz") +add_executable(pp_app_core_canvas_hotkey_tests + app_core/canvas_hotkey_tests.cpp) +target_link_libraries(pp_app_core_canvas_hotkey_tests PRIVATE + pp_app_core + pp_test_harness) + +add_test(NAME pp_app_core_canvas_hotkey_tests COMMAND pp_app_core_canvas_hotkey_tests) +set_tests_properties(pp_app_core_canvas_hotkey_tests PROPERTIES + LABELS "app;ui;document;paint;desktop-fast;fuzz") + add_executable(pp_app_core_grid_ui_tests app_core/grid_ui_tests.cpp) target_link_libraries(pp_app_core_grid_ui_tests PRIVATE @@ -1274,6 +1284,36 @@ if(TARGET pano_cli) LABELS "renderer;paint;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_canvas_hotkey_ctrl_z_smoke + COMMAND pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2) + set_tests_properties(pano_cli_plan_canvas_hotkey_ctrl_z_smoke PROPERTIES + LABELS "app;document;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-hotkey\".*\"key\":\"z\".*\"ctrl\":true.*\"action\":\"history\".*\"historyOperation\":\"undo\".*\"historyNoOp\":false") + + add_test(NAME pano_cli_plan_canvas_hotkey_save_dirty_version_smoke + COMMAND pano_cli plan-canvas-hotkey --event key-up --key s --ctrl --shift) + set_tests_properties(pano_cli_plan_canvas_hotkey_save_dirty_version_smoke PROPERTIES + LABELS "app;document;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-hotkey\".*\"key\":\"s\".*\"shift\":true.*\"action\":\"save-document\".*\"saveIntent\":\"save-dirty-version\"") + + add_test(NAME pano_cli_plan_canvas_hotkey_erase_smoke + COMMAND pano_cli plan-canvas-hotkey --event key-down --key e) + set_tests_properties(pano_cli_plan_canvas_hotkey_erase_smoke PROPERTIES + LABELS "app;paint;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-hotkey\".*\"event\":\"key-down\".*\"key\":\"e\".*\"action\":\"select-tool\".*\"toolMode\":\"erase\"") + + add_test(NAME pano_cli_plan_canvas_hotkey_two_finger_undo_smoke + COMMAND pano_cli plan-canvas-hotkey --event touch-tap --key other --touch-fingers 2 --undo-count 1) + set_tests_properties(pano_cli_plan_canvas_hotkey_two_finger_undo_smoke PROPERTIES + LABELS "app;document;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-hotkey\".*\"event\":\"touch-tap\".*\"touchFingers\":2.*\"action\":\"history\".*\"historyOperation\":\"undo\"") + + add_test(NAME pano_cli_plan_canvas_hotkey_rejects_bad_count + COMMAND pano_cli plan-canvas-hotkey --event key-down --key android-back --undo-count -1) + set_tests_properties(pano_cli_plan_canvas_hotkey_rejects_bad_count PROPERTIES + LABELS "app;document;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_canvas_tool_draw_smoke COMMAND pano_cli plan-canvas-tool --kind draw) set_tests_properties(pano_cli_plan_canvas_tool_draw_smoke PROPERTIES diff --git a/tests/app_core/canvas_hotkey_tests.cpp b/tests/app_core/canvas_hotkey_tests.cpp new file mode 100644 index 0000000..151a033 --- /dev/null +++ b/tests/app_core/canvas_hotkey_tests.cpp @@ -0,0 +1,297 @@ +#include "app_core/canvas_hotkey.h" +#include "test_harness.h" + +#include + +namespace { + +class FakeCanvasHotkeyServices final : public pp::app::CanvasHotkeyServices { +public: + pp::foundation::Status execute_tool(const pp::app::CanvasToolPlan& plan) override + { + tool_calls += 1; + last_tool_mode = plan.mode; + return pp::foundation::Status::success(); + } + + pp::foundation::Status execute_history(const pp::app::HistoryUiPlan& plan) override + { + history_calls += 1; + last_history_operation = plan.operation; + return pp::foundation::Status::success(); + } + + void save_document(pp::app::DocumentSaveIntent intent) override + { + save_calls += 1; + last_save_intent = intent; + } + + void toggle_ui() override { toggle_ui_calls += 1; } + + void adjust_brush_size(float delta) override + { + brush_adjust_calls += 1; + last_brush_delta = delta; + } + + void show_cursor() override { show_cursor_calls += 1; } + + int tool_calls = 0; + int history_calls = 0; + int save_calls = 0; + int toggle_ui_calls = 0; + int brush_adjust_calls = 0; + int show_cursor_calls = 0; + pp::app::CanvasToolMode last_tool_mode = pp::app::CanvasToolMode::draw; + pp::app::HistoryUiOperation last_history_operation = pp::app::HistoryUiOperation::undo; + pp::app::DocumentSaveIntent last_save_intent = pp::app::DocumentSaveIntent::save; + float last_brush_delta = 0.0F; +}; + +pp::app::CanvasHotkeyState default_state() noexcept +{ + pp::app::CanvasHotkeyState state; + state.undo_count = 2; + state.redo_count = 1; + return state; +} + +void e_key_toggles_erase_and_draw(pp::tests::Harness& harness) +{ + const auto down = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + pp::app::CanvasHotkeyKey::e, + default_state()); + const auto up = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::e, + default_state()); + + PP_EXPECT(harness, down); + PP_EXPECT(harness, up); + if (down) { + PP_EXPECT(harness, down.value().action == pp::app::CanvasHotkeyAction::select_tool); + PP_EXPECT(harness, down.value().tool.mode == pp::app::CanvasToolMode::erase); + PP_EXPECT(harness, !down.value().no_op); + } + if (up) { + PP_EXPECT(harness, up.value().action == pp::app::CanvasHotkeyAction::select_tool); + PP_EXPECT(harness, up.value().tool.mode == pp::app::CanvasToolMode::draw); + PP_EXPECT(harness, !up.value().no_op); + } +} + +void ctrl_z_and_shift_z_plan_history(pp::tests::Harness& harness) +{ + auto state = default_state(); + state.ctrl_down = true; + const auto undo = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::z, + state); + state.shift_down = true; + const auto redo = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::z, + state); + + PP_EXPECT(harness, undo); + PP_EXPECT(harness, redo); + if (undo) { + PP_EXPECT(harness, undo.value().action == pp::app::CanvasHotkeyAction::history); + PP_EXPECT(harness, undo.value().history.operation == pp::app::HistoryUiOperation::undo); + PP_EXPECT(harness, undo.value().history.invokes_undo); + } + if (redo) { + PP_EXPECT(harness, redo.value().action == pp::app::CanvasHotkeyAction::history); + PP_EXPECT(harness, redo.value().history.operation == pp::app::HistoryUiOperation::redo); + PP_EXPECT(harness, redo.value().history.invokes_redo); + } +} + +void save_hotkeys_plan_document_intents(pp::tests::Harness& harness) +{ + auto state = default_state(); + state.ctrl_down = true; + const auto save = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::s, + state); + state.shift_down = true; + const auto save_version = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::s, + state); + + PP_EXPECT(harness, save); + PP_EXPECT(harness, save_version); + if (save) { + PP_EXPECT(harness, save.value().action == pp::app::CanvasHotkeyAction::save_document); + PP_EXPECT(harness, save.value().save_intent == pp::app::DocumentSaveIntent::save); + } + if (save_version) { + PP_EXPECT(harness, save_version.value().action == pp::app::CanvasHotkeyAction::save_document); + PP_EXPECT( + harness, + save_version.value().save_intent == pp::app::DocumentSaveIntent::save_dirty_version); + } +} + +void app_and_brush_hotkeys_are_planned(pp::tests::Harness& harness) +{ + auto state = default_state(); + state.mouse_focused = true; + const auto alt = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + pp::app::CanvasHotkeyKey::alt, + state); + const auto tab = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::tab, + state); + const auto smaller = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::bracket_left, + state); + const auto larger = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::bracket_right, + state); + + PP_EXPECT(harness, alt); + PP_EXPECT(harness, tab); + PP_EXPECT(harness, smaller); + PP_EXPECT(harness, larger); + if (alt) { + PP_EXPECT(harness, alt.value().action == pp::app::CanvasHotkeyAction::show_cursor); + } + if (tab) { + PP_EXPECT(harness, tab.value().action == pp::app::CanvasHotkeyAction::toggle_ui); + } + if (smaller) { + PP_EXPECT(harness, smaller.value().brush_size_delta < 0.0F); + } + if (larger) { + PP_EXPECT(harness, larger.value().brush_size_delta > 0.0F); + } +} + +void touch_and_android_back_plan_undo(pp::tests::Harness& harness) +{ + auto state = default_state(); + const auto android = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + pp::app::CanvasHotkeyKey::android_back, + state); + state.touch_finger_count = 2; + const auto tap = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::touch_tap, + pp::app::CanvasHotkeyKey::other, + state); + + PP_EXPECT(harness, android); + PP_EXPECT(harness, tap); + if (android) { + PP_EXPECT(harness, android.value().action == pp::app::CanvasHotkeyAction::history); + PP_EXPECT(harness, android.value().history.operation == pp::app::HistoryUiOperation::undo); + } + if (tap) { + PP_EXPECT(harness, tap.value().action == pp::app::CanvasHotkeyAction::history); + PP_EXPECT(harness, tap.value().history.operation == pp::app::HistoryUiOperation::undo); + } +} + +void planner_preserves_no_ops_and_rejects_bad_counts(pp::tests::Harness& harness) +{ + auto state = default_state(); + const auto no_ctrl_z = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::z, + state); + state.undo_count = -1; + const auto bad_undo = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + pp::app::CanvasHotkeyKey::android_back, + state); + + PP_EXPECT(harness, no_ctrl_z); + if (no_ctrl_z) { + PP_EXPECT(harness, no_ctrl_z.value().action == pp::app::CanvasHotkeyAction::none); + PP_EXPECT(harness, no_ctrl_z.value().no_op); + } + PP_EXPECT(harness, !bad_undo); + if (!bad_undo) { + PP_EXPECT(harness, bad_undo.status().code == pp::foundation::StatusCode::out_of_range); + } +} + +void executor_dispatches_actions(pp::tests::Harness& harness) +{ + FakeCanvasHotkeyServices services; + auto state = default_state(); + state.ctrl_down = true; + + const auto save = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::s, + state); + const auto undo = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::z, + state); + const auto erase = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_down, + pp::app::CanvasHotkeyKey::e, + state); + const auto brush = pp::app::plan_canvas_hotkey( + pp::app::CanvasHotkeyEvent::key_up, + pp::app::CanvasHotkeyKey::bracket_right, + state); + + PP_EXPECT(harness, save && undo && erase && brush); + if (save && undo && erase && brush) { + PP_EXPECT(harness, pp::app::execute_canvas_hotkey_plan(save.value(), services).ok()); + PP_EXPECT(harness, pp::app::execute_canvas_hotkey_plan(undo.value(), services).ok()); + PP_EXPECT(harness, pp::app::execute_canvas_hotkey_plan(erase.value(), services).ok()); + PP_EXPECT(harness, pp::app::execute_canvas_hotkey_plan(brush.value(), services).ok()); + } + + PP_EXPECT(harness, services.save_calls == 1); + PP_EXPECT(harness, services.history_calls == 1); + PP_EXPECT(harness, services.tool_calls == 1); + PP_EXPECT(harness, services.brush_adjust_calls == 1); + PP_EXPECT(harness, services.last_save_intent == pp::app::DocumentSaveIntent::save); + PP_EXPECT(harness, services.last_history_operation == pp::app::HistoryUiOperation::undo); + PP_EXPECT(harness, services.last_tool_mode == pp::app::CanvasToolMode::erase); + PP_EXPECT(harness, std::fabs(services.last_brush_delta - 0.05F) < 0.001F); +} + +void executor_rejects_malformed_brush_adjust(pp::tests::Harness& harness) +{ + FakeCanvasHotkeyServices services; + pp::app::CanvasHotkeyPlan malformed; + malformed.action = pp::app::CanvasHotkeyAction::adjust_brush_size; + malformed.no_op = false; + + const auto status = pp::app::execute_canvas_hotkey_plan(malformed, services); + PP_EXPECT(harness, !status.ok()); + PP_EXPECT(harness, status.code == pp::foundation::StatusCode::invalid_argument); + PP_EXPECT(harness, services.brush_adjust_calls == 0); +} + +} // namespace + +int main() +{ + pp::tests::Harness harness; + harness.run("e key toggles erase and draw", e_key_toggles_erase_and_draw); + harness.run("ctrl z and shift z plan history", ctrl_z_and_shift_z_plan_history); + harness.run("save hotkeys plan document intents", save_hotkeys_plan_document_intents); + harness.run("app and brush hotkeys are planned", app_and_brush_hotkeys_are_planned); + harness.run("touch and android back plan undo", touch_and_android_back_plan_undo); + harness.run("planner preserves no ops and rejects bad counts", planner_preserves_no_ops_and_rejects_bad_counts); + harness.run("executor dispatches actions", executor_dispatches_actions); + harness.run("executor rejects malformed brush adjust", executor_rejects_malformed_brush_adjust); + return harness.finish(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index d629e3b..e5ff24d 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -2,6 +2,7 @@ #include "app_core/app_preferences.h" #include "app_core/app_status.h" #include "app_core/brush_ui.h" +#include "app_core/canvas_hotkey.h" #include "app_core/canvas_tool_ui.h" #include "app_core/document_animation.h" #include "app_core/document_canvas.h" @@ -406,6 +407,17 @@ struct PlanCanvasToolArgs { bool current_mode_draw = false; }; +struct PlanCanvasHotkeyArgs { + std::string event = "key-up"; + std::string key = "z"; + bool ctrl_down = false; + bool shift_down = false; + bool mouse_focused = false; + int undo_count = 1; + int redo_count = 1; + int touch_finger_count = 0; +}; + struct PlanCanvasToolStateArgs { std::string mode = "draw"; bool picking = false; @@ -1323,6 +1335,68 @@ const char* canvas_tool_transform_action_name(pp::app::CanvasToolTransformAction return "none"; } +const char* canvas_hotkey_event_name(pp::app::CanvasHotkeyEvent event) noexcept +{ + switch (event) { + case pp::app::CanvasHotkeyEvent::key_down: + return "key-down"; + case pp::app::CanvasHotkeyEvent::key_up: + return "key-up"; + case pp::app::CanvasHotkeyEvent::touch_tap: + return "touch-tap"; + } + + return "key-up"; +} + +const char* canvas_hotkey_key_name(pp::app::CanvasHotkeyKey key) noexcept +{ + switch (key) { + case pp::app::CanvasHotkeyKey::other: + return "other"; + case pp::app::CanvasHotkeyKey::android_back: + return "android-back"; + case pp::app::CanvasHotkeyKey::alt: + return "alt"; + case pp::app::CanvasHotkeyKey::e: + return "e"; + case pp::app::CanvasHotkeyKey::s: + return "s"; + case pp::app::CanvasHotkeyKey::tab: + return "tab"; + case pp::app::CanvasHotkeyKey::z: + return "z"; + case pp::app::CanvasHotkeyKey::bracket_left: + return "bracket-left"; + case pp::app::CanvasHotkeyKey::bracket_right: + return "bracket-right"; + } + + return "other"; +} + +const char* canvas_hotkey_action_name(pp::app::CanvasHotkeyAction action) noexcept +{ + switch (action) { + case pp::app::CanvasHotkeyAction::none: + return "none"; + case pp::app::CanvasHotkeyAction::select_tool: + return "select-tool"; + case pp::app::CanvasHotkeyAction::history: + return "history"; + case pp::app::CanvasHotkeyAction::save_document: + return "save-document"; + case pp::app::CanvasHotkeyAction::toggle_ui: + return "toggle-ui"; + case pp::app::CanvasHotkeyAction::adjust_brush_size: + return "adjust-brush-size"; + case pp::app::CanvasHotkeyAction::show_cursor: + return "show-cursor"; + } + + return "none"; +} + const char* grid_ui_operation_name(pp::app::GridUiOperation operation) noexcept { switch (operation) { @@ -1796,6 +1870,7 @@ void print_help() << " plan-brush-stroke-control --kind float|bool|blend|tip-aspect-reset|default-reset [--setting NAME] [--value N] [--enabled|--disabled] [--blend-mode N]\n" << " plan-paint-feedback [--width N] [--height N] [--simple|--complex] [--framebuffer-fetch] [--texture-copy] [--blit] [--explicit-transitions] [--render-only] [--depth]\n" << " plan-stroke-composite [--width N] [--height N] [--layer-blend N] [--stroke-blend N] [--dual-blend] [--pattern-blend] [--framebuffer-fetch] [--texture-copy] [--blit] [--explicit-transitions] [--render-only] [--depth]\n" + << " 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-grid-operation --kind pick|load|reload|clear|render|commit [--path FILE] [--no-heightmap] [--no-canvas] [--float32] [--float16] [--texture-resolution N] [--samples N]\n" @@ -5340,6 +5415,166 @@ int plan_canvas_tool(int argc, char** argv) return 0; } +pp::foundation::Result parse_canvas_hotkey_event(std::string_view event) +{ + if (event == "key-down") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyEvent::key_down); + } + if (event == "key-up") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyEvent::key_up); + } + if (event == "touch-tap") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyEvent::touch_tap); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown canvas hotkey event")); +} + +pp::foundation::Result parse_canvas_hotkey_key(std::string_view key) +{ + if (key == "other") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyKey::other); + } + if (key == "android-back") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyKey::android_back); + } + if (key == "alt") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyKey::alt); + } + if (key == "e") { + return pp::foundation::Result::success(pp::app::CanvasHotkeyKey::e); + } + if (key == "s") { + return pp::foundation::Result::success(pp::app::CanvasHotkeyKey::s); + } + if (key == "tab") { + return pp::foundation::Result::success(pp::app::CanvasHotkeyKey::tab); + } + if (key == "z") { + return pp::foundation::Result::success(pp::app::CanvasHotkeyKey::z); + } + if (key == "bracket-left") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyKey::bracket_left); + } + if (key == "bracket-right") { + return pp::foundation::Result::success( + pp::app::CanvasHotkeyKey::bracket_right); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown canvas hotkey key")); +} + +pp::foundation::Status parse_plan_canvas_hotkey_args( + int argc, + char** argv, + PlanCanvasHotkeyArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--event" || key == "--key") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + if (key == "--event") { + args.event = argv[++i]; + } else { + args.key = argv[++i]; + } + } else if (key == "--undo-count" || key == "--redo-count" || key == "--touch-fingers") { + 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 == "--undo-count") { + args.undo_count = value.value(); + } else if (key == "--redo-count") { + args.redo_count = value.value(); + } else { + args.touch_finger_count = value.value(); + } + } else if (key == "--ctrl") { + args.ctrl_down = true; + } else if (key == "--shift") { + args.shift_down = true; + } else if (key == "--mouse-focus") { + args.mouse_focused = true; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int plan_canvas_hotkey(int argc, char** argv) +{ + PlanCanvasHotkeyArgs args; + const auto status = parse_plan_canvas_hotkey_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-canvas-hotkey", status.message); + return 2; + } + + const auto event = parse_canvas_hotkey_event(args.event); + if (!event) { + print_error("plan-canvas-hotkey", event.status().message); + return 2; + } + const auto key = parse_canvas_hotkey_key(args.key); + if (!key) { + print_error("plan-canvas-hotkey", key.status().message); + return 2; + } + + pp::app::CanvasHotkeyState state; + state.ctrl_down = args.ctrl_down; + state.shift_down = args.shift_down; + state.mouse_focused = args.mouse_focused; + state.undo_count = args.undo_count; + state.redo_count = args.redo_count; + state.touch_finger_count = args.touch_finger_count; + + const auto plan = pp::app::plan_canvas_hotkey(event.value(), key.value(), state); + if (!plan) { + print_error("plan-canvas-hotkey", plan.status().message); + return 2; + } + + const auto& value = plan.value(); + std::cout << "{\"ok\":true,\"command\":\"plan-canvas-hotkey\"" + << ",\"state\":{\"event\":\"" << json_escape(args.event) + << "\",\"key\":\"" << json_escape(args.key) + << "\",\"ctrl\":" << json_bool(args.ctrl_down) + << ",\"shift\":" << json_bool(args.shift_down) + << ",\"mouseFocus\":" << json_bool(args.mouse_focused) + << ",\"undoCount\":" << args.undo_count + << ",\"redoCount\":" << args.redo_count + << ",\"touchFingers\":" << args.touch_finger_count + << "},\"plan\":{\"event\":\"" << canvas_hotkey_event_name(value.event) + << "\",\"key\":\"" << canvas_hotkey_key_name(value.key) + << "\",\"action\":\"" << canvas_hotkey_action_name(value.action) + << "\",\"toolMode\":\"" << canvas_tool_mode_name(value.tool.mode) + << "\",\"historyOperation\":\"" << history_ui_operation_name(value.history.operation) + << "\",\"historyNoOp\":" << json_bool(value.history.no_op) + << ",\"saveIntent\":\"" << document_save_intent_name(value.save_intent) + << "\",\"brushSizeDelta\":" << value.brush_size_delta + << ",\"noOp\":" << json_bool(value.no_op) + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_canvas_tool_state_args( int argc, char** argv, @@ -8329,6 +8564,10 @@ int main(int argc, char** argv) return plan_stroke_composite(argc, argv); } + if (command == "plan-canvas-hotkey") { + return plan_canvas_hotkey(argc, argv); + } + if (command == "plan-canvas-tool") { return plan_canvas_tool(argc, argv); }