Route canvas hotkeys through app core

This commit is contained in:
2026-06-03 20:30:07 +02:00
parent 16a1d1e15b
commit 6945ce7e23
8 changed files with 953 additions and 52 deletions

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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`,

View File

@@ -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<CanvasHotkeyPlan> 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<CanvasHotkeyPlan>::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<CanvasHotkeyPlan>::failure(history.status());
}
plan.action = CanvasHotkeyAction::history;
plan.history = history.value();
plan.no_op = plan.history.no_op;
}
return pp::foundation::Result<CanvasHotkeyPlan>::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<CanvasHotkeyPlan>::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<CanvasHotkeyPlan>::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<CanvasHotkeyPlan>::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<CanvasHotkeyPlan>::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

View File

@@ -5,6 +5,7 @@
#include <memory>
#include <vector>
#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<int>(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<int>(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<float>(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<int>(ActionManager::I.m_actions.size());
state.redo_count = static_cast<int>(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<float>(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<float>(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;

View File

@@ -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

View File

@@ -0,0 +1,297 @@
#include "app_core/canvas_hotkey.h"
#include "test_harness.h"
#include <cmath>
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();
}

View File

@@ -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<pp::app::CanvasHotkeyEvent> parse_canvas_hotkey_event(std::string_view event)
{
if (event == "key-down") {
return pp::foundation::Result<pp::app::CanvasHotkeyEvent>::success(
pp::app::CanvasHotkeyEvent::key_down);
}
if (event == "key-up") {
return pp::foundation::Result<pp::app::CanvasHotkeyEvent>::success(
pp::app::CanvasHotkeyEvent::key_up);
}
if (event == "touch-tap") {
return pp::foundation::Result<pp::app::CanvasHotkeyEvent>::success(
pp::app::CanvasHotkeyEvent::touch_tap);
}
return pp::foundation::Result<pp::app::CanvasHotkeyEvent>::failure(
pp::foundation::Status::invalid_argument("unknown canvas hotkey event"));
}
pp::foundation::Result<pp::app::CanvasHotkeyKey> parse_canvas_hotkey_key(std::string_view key)
{
if (key == "other") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(
pp::app::CanvasHotkeyKey::other);
}
if (key == "android-back") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(
pp::app::CanvasHotkeyKey::android_back);
}
if (key == "alt") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(
pp::app::CanvasHotkeyKey::alt);
}
if (key == "e") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(pp::app::CanvasHotkeyKey::e);
}
if (key == "s") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(pp::app::CanvasHotkeyKey::s);
}
if (key == "tab") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(pp::app::CanvasHotkeyKey::tab);
}
if (key == "z") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(pp::app::CanvasHotkeyKey::z);
}
if (key == "bracket-left") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(
pp::app::CanvasHotkeyKey::bracket_left);
}
if (key == "bracket-right") {
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::success(
pp::app::CanvasHotkeyKey::bracket_right);
}
return pp::foundation::Result<pp::app::CanvasHotkeyKey>::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);
}