Add animation panel service boundary

This commit is contained in:
2026-06-03 14:04:36 +02:00
parent 9c7c89fed4
commit 93f3037410
6 changed files with 457 additions and 78 deletions

View File

@@ -39,7 +39,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0019 | Open | Modernization | Unreferenced-parameter warnings are muted globally through `pp_project_warnings` with MSVC `/wd4100` and Clang/GCC `-Wno-unused-parameter` | Legacy callbacks, virtual hooks, serializer methods, and platform/API compatibility functions carry many intentionally unused parameters during the component split; muting this keeps stricter warning builds focused on higher-signal migration issues | `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset linux-clang --target pp_foundation` | Remove `/wd4100` and `-Wno-unused-parameter`, mark intentionally unused parameters with names/comments or `[[maybe_unused]]`, and make the Windows app plus headless Clang/GCC tests pass without unreferenced-parameter warnings | | DEBT-0019 | Open | Modernization | Unreferenced-parameter warnings are muted globally through `pp_project_warnings` with MSVC `/wd4100` and Clang/GCC `-Wno-unused-parameter` | Legacy callbacks, virtual hooks, serializer methods, and platform/API compatibility functions carry many intentionally unused parameters during the component split; muting this keeps stricter warning builds focused on higher-signal migration issues | `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset linux-clang --target pp_foundation` | Remove `/wd4100` and `-Wno-unused-parameter`, mark intentionally unused parameters with names/comments or `[[maybe_unused]]`, and make the Windows app plus headless Clang/GCC tests pass without unreferenced-parameter warnings |
| DEBT-0020 | Open | Modernization | Document resize dialog state, selected-resolution planning, and execution dispatch now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, `pano_cli plan-document-resize`, and the `DocumentResizeServices` boundary, but the live adapter still calls legacy `Canvas::resize`, updates the legacy app title, and clears legacy `ActionManager` history | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by injected document/app services with no legacy resize adapter, title shim, or direct `ActionManager` history clearing | | DEBT-0020 | Open | Modernization | Document resize dialog state, selected-resolution planning, and execution dispatch now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, `pano_cli plan-document-resize`, and the `DocumentResizeServices` boundary, but the live adapter still calls legacy `Canvas::resize`, updates the legacy app title, and clears legacy `ActionManager` history | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by injected document/app services with no legacy resize adapter, title shim, or direct `ActionManager` history clearing |
| DEBT-0021 | Open | Modernization | Layer rename and layer panel operation planning now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, and `pano_cli plan-layer-operation`, but live execution still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely | | DEBT-0021 | Open | Modernization | Layer rename and layer panel operation planning now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, and `pano_cli plan-layer-operation`, but live execution still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely |
| DEBT-0022 | Open | Modernization | Animation panel frame command planning now consumes pure `pp_app_core` through `NodePanelAnimation` and `pano_cli plan-animation-operation`, and `pp_legacy_ui_core` temporarily links `pp_app_core`, but live execution still mutates legacy `Canvas`/`Layer` frame state and animation playback state directly | Preserve existing animation panel behavior while timeline/frame commands move toward the document/app command boundary | `pp_app_core_document_animation_tests`; `pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0`; `pano_cli plan-animation-operation --kind next --total-duration 5 --current-frame 4`; `ctest --preset desktop-fast --build-config Debug` | Animation frame/timeline execution is owned by the document/app command boundary with legacy `Canvas`/`Layer`/UI nodes acting only as adapters or removed entirely | | DEBT-0022 | Open | Modernization | Animation panel frame command planning and panel-control/timeline execution dispatch now consume pure `pp_app_core` through `NodePanelAnimation`, `pano_cli plan-animation-operation`, and `DocumentAnimationServices`, and `pp_legacy_ui_core` temporarily links `pp_app_core`, but selected-frame click handling, playback/play-mode toggles, and the live adapter still mutate or read legacy `Canvas`/`Layer` frame state directly | Preserve existing animation panel behavior while timeline/frame commands move toward the document/app command boundary | `pp_app_core_document_animation_tests`; `pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0`; `pano_cli plan-animation-operation --kind next --total-duration 5 --current-frame 4`; `ctest --preset desktop-fast --build-config Debug` | Animation frame/timeline execution is owned by injected document/app timeline services with no legacy `Canvas`/`Layer` adapter and UI nodes acting only as adapters or removed entirely |
| DEBT-0023 | Open | Modernization | Brush/color/preset/stroke-settings UI planning and execution dispatch now consume pure `pp_app_core` through `App::init_sidebar`, restored/docked floating-panel callbacks, `pano_cli plan-brush-operation`, and the `BrushUiServices` boundary, but the live adapter still mutates legacy `Brush`, calls legacy brush texture loading, and refreshes legacy quick/stroke/color widgets | Preserve existing brush UI behavior while brush commands move toward a brush/app command boundary and asset-managed texture selection | `pp_app_core_brush_ui_tests`; `pano_cli plan-brush-operation --kind color --r 0.25 --g 0.5 --b 0.75 --a 1`; `pano_cli plan-brush-operation --kind pattern --path data/patterns/noise.png --thumb data/patterns/thumbs/noise.png`; `ctest --preset desktop-fast --build-config Debug` | Brush color/texture/preset/stroke-settings execution is owned by injected brush/app/asset/UI services with no legacy brush adapter | | DEBT-0023 | Open | Modernization | Brush/color/preset/stroke-settings UI planning and execution dispatch now consume pure `pp_app_core` through `App::init_sidebar`, restored/docked floating-panel callbacks, `pano_cli plan-brush-operation`, and the `BrushUiServices` boundary, but the live adapter still mutates legacy `Brush`, calls legacy brush texture loading, and refreshes legacy quick/stroke/color widgets | Preserve existing brush UI behavior while brush commands move toward a brush/app command boundary and asset-managed texture selection | `pp_app_core_brush_ui_tests`; `pano_cli plan-brush-operation --kind color --r 0.25 --g 0.5 --b 0.75 --a 1`; `pano_cli plan-brush-operation --kind pattern --path data/patterns/noise.png --thumb data/patterns/thumbs/noise.png`; `ctest --preset desktop-fast --build-config Debug` | Brush color/texture/preset/stroke-settings execution is owned by injected brush/app/asset/UI services with no legacy brush adapter |
| 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-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-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 |

View File

@@ -499,7 +499,9 @@ adapter continues execution.
`pano_cli plan-animation-operation` exposes app-core planning for animation `pano_cli plan-animation-operation` exposes app-core planning for animation
frame add, duplicate, remove, duration adjustment, timeline moves, timeline frame add, duplicate, remove, duration adjustment, timeline moves, timeline
goto/next/previous, and onion-size updates used by the live animation panel goto/next/previous, and onion-size updates used by the live animation panel
before legacy `Canvas`/`Layer` frame execution continues. Panel-control and timeline execution now dispatch through
`DocumentAnimationServices` before the legacy `Canvas`/`Layer` adapter
continues.
`pano_cli plan-brush-operation` exposes app-core planning for brush color `pano_cli plan-brush-operation` exposes app-core planning for brush color
changes, tip/pattern/dual texture changes, preset brush replacement, and stroke changes, tip/pattern/dual texture changes, preset brush replacement, and stroke
settings refreshes used by the live brush, quick, color, and floating panel settings refreshes used by the live brush, quick, color, and floating panel
@@ -1200,7 +1202,8 @@ Results:
- `pp_app_core_document_animation_tests` passed, covering animation frame - `pp_app_core_document_animation_tests` passed, covering animation frame
add/duplicate/remove planning, selected-frame rejection, last-frame remove add/duplicate/remove planning, selected-frame rejection, last-frame remove
rejection, duration floor/overflow handling, timeline move edge behavior, rejection, duration floor/overflow handling, timeline move edge behavior,
goto/next/previous wrapping, and onion-size rejection. goto/next/previous wrapping, onion-size rejection, service dispatch ordering,
non-mutating duration no-ops, and malformed execution payload rejection.
- `pano_cli_plan_animation_operation_add_smoke`, - `pano_cli_plan_animation_operation_add_smoke`,
`pano_cli_plan_animation_operation_duration_floor_smoke`, `pano_cli_plan_animation_operation_duration_floor_smoke`,
`pano_cli_plan_animation_operation_next_wrap_smoke`, and `pano_cli_plan_animation_operation_next_wrap_smoke`, and

View File

@@ -39,6 +39,22 @@ struct DocumentAnimationOperationPlan {
bool marks_unsaved = false; bool marks_unsaved = false;
}; };
class DocumentAnimationServices {
public:
virtual ~DocumentAnimationServices() = default;
virtual void add_frame() = 0;
virtual void duplicate_frame(int selected_frame) = 0;
virtual void remove_frame(int selected_frame, int target_frame) = 0;
virtual void set_frame_duration(int selected_frame, int duration) = 0;
virtual int move_frame(int selected_frame, int move_offset) = 0;
virtual void goto_frame(int target_frame) = 0;
virtual void set_onion_size(int onion_size) = 0;
virtual void update_canvas_animation() = 0;
virtual void reload_animation_layers() = 0;
virtual void mark_unsaved() = 0;
};
[[nodiscard]] inline pp::foundation::Status validate_animation_frame_count(int frame_count) noexcept [[nodiscard]] inline pp::foundation::Status validate_animation_frame_count(int frame_count) noexcept
{ {
if (frame_count <= 0) { if (frame_count <= 0) {
@@ -288,4 +304,145 @@ struct DocumentAnimationOperationPlan {
return pp::foundation::Result<DocumentAnimationOperationPlan>::success(plan); return pp::foundation::Result<DocumentAnimationOperationPlan>::success(plan);
} }
[[nodiscard]] inline pp::foundation::Status validate_animation_operation_plan(
const DocumentAnimationOperationPlan& plan) noexcept
{
switch (plan.operation) {
case DocumentAnimationOperation::add_frame:
if (!plan.mutates_document || !plan.marks_unsaved) {
return pp::foundation::Status::invalid_argument("animation add plan must mutate the document");
}
return validate_animation_frame_count(plan.frame_count);
case DocumentAnimationOperation::duplicate_frame:
case DocumentAnimationOperation::remove_frame:
if (!plan.requires_selected_frame || !plan.mutates_document || !plan.marks_unsaved) {
return pp::foundation::Status::invalid_argument("animation selected-frame plan must mutate the document");
}
if (plan.operation == DocumentAnimationOperation::remove_frame && plan.frame_count <= 1) {
return pp::foundation::Status::invalid_argument("animation layer must keep at least one frame");
}
return validate_animation_frame_index(plan.frame_count, plan.selected_frame);
case DocumentAnimationOperation::adjust_duration:
if (!plan.requires_selected_frame) {
return pp::foundation::Status::invalid_argument("animation duration plan must require a selected frame");
}
{
const auto index_status = validate_animation_frame_index(plan.frame_count, plan.selected_frame);
if (!index_status.ok()) {
return index_status;
}
}
return validate_animation_frame_duration(plan.frame_duration);
case DocumentAnimationOperation::move_frame:
if (!plan.requires_selected_frame || plan.move_offset == 0) {
return pp::foundation::Status::invalid_argument("animation move plan must require selected frame and non-zero offset");
}
return validate_animation_frame_index(plan.frame_count, plan.selected_frame);
case DocumentAnimationOperation::goto_frame:
case DocumentAnimationOperation::goto_next:
case DocumentAnimationOperation::goto_previous:
if (!plan.updates_canvas_animation) {
return pp::foundation::Status::invalid_argument("animation goto plan must update canvas animation");
}
return validate_animation_frame_index(plan.frame_count, plan.target_frame);
case DocumentAnimationOperation::set_onion_size:
if (plan.onion_size < 0) {
return pp::foundation::Status::invalid_argument("animation onion size must not be negative");
}
if (!plan.updates_canvas_animation) {
return pp::foundation::Status::invalid_argument("animation onion plan must update canvas animation");
}
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown animation operation");
}
[[nodiscard]] inline pp::foundation::Status execute_animation_operation_plan(
const DocumentAnimationOperationPlan& plan,
DocumentAnimationServices& services)
{
const auto validation = validate_animation_operation_plan(plan);
if (!validation.ok()) {
return validation;
}
switch (plan.operation) {
case DocumentAnimationOperation::add_frame:
services.add_frame();
services.mark_unsaved();
services.update_canvas_animation();
services.reload_animation_layers();
return pp::foundation::Status::success();
case DocumentAnimationOperation::duplicate_frame:
services.duplicate_frame(plan.selected_frame);
services.mark_unsaved();
services.update_canvas_animation();
services.reload_animation_layers();
return pp::foundation::Status::success();
case DocumentAnimationOperation::remove_frame:
services.remove_frame(plan.selected_frame, plan.target_frame);
services.mark_unsaved();
if (plan.updates_canvas_animation) {
services.goto_frame(plan.target_frame);
}
if (plan.reloads_animation_layers) {
services.reload_animation_layers();
}
return pp::foundation::Status::success();
case DocumentAnimationOperation::adjust_duration:
if (plan.mutates_document) {
services.set_frame_duration(plan.selected_frame, plan.frame_duration);
services.mark_unsaved();
if (plan.updates_canvas_animation) {
services.update_canvas_animation();
}
if (plan.reloads_animation_layers) {
services.reload_animation_layers();
}
}
return pp::foundation::Status::success();
case DocumentAnimationOperation::move_frame:
{
const auto actual_target_frame = services.move_frame(plan.selected_frame, plan.move_offset);
if (plan.marks_unsaved) {
services.mark_unsaved();
}
if (plan.updates_canvas_animation) {
services.goto_frame(actual_target_frame);
}
if (plan.reloads_animation_layers) {
services.reload_animation_layers();
}
return pp::foundation::Status::success();
}
case DocumentAnimationOperation::goto_frame:
case DocumentAnimationOperation::goto_next:
case DocumentAnimationOperation::goto_previous:
services.goto_frame(plan.target_frame);
if (plan.reloads_animation_layers) {
services.reload_animation_layers();
}
return pp::foundation::Status::success();
case DocumentAnimationOperation::set_onion_size:
services.set_onion_size(plan.onion_size);
if (plan.updates_canvas_animation) {
services.update_canvas_animation();
}
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown animation operation");
}
} // namespace pp::app } // namespace pp::app

View File

@@ -26,6 +26,85 @@ void NodePanelAnimation::init()
init_controls(); init_controls();
} }
void NodePanelAnimation::execute_animation_plan(const pp::app::DocumentAnimationOperationPlan& plan, Layer* layer)
{
class LegacyAnimationServices final : public pp::app::DocumentAnimationServices {
public:
LegacyAnimationServices(NodePanelAnimation& panel, Layer* layer) noexcept
: panel_(panel)
, layer_(layer)
{
}
void add_frame() override
{
Canvas::I->layer().add_frame();
}
void duplicate_frame(int selected_frame) override
{
if (layer_)
layer_->duplicate_frame(selected_frame);
}
void remove_frame(int selected_frame, int target_frame) override
{
if (!layer_)
return;
layer_->remove_frame(selected_frame);
panel_.m_selected_frame_index = target_frame;
}
void set_frame_duration(int selected_frame, int duration) override
{
if (layer_)
layer_->set_frame_duration(selected_frame, duration);
}
int move_frame(int selected_frame, int move_offset) override
{
if (!layer_)
return selected_frame;
panel_.m_selected_frame_index = layer_->move_frame_offset(selected_frame, move_offset);
return panel_.m_selected_frame_index;
}
void goto_frame(int target_frame) override
{
Canvas::I->anim_goto_frame(target_frame);
}
void set_onion_size(int onion_size) override
{
panel_.m_timeline->m_onion_size = onion_size;
}
void update_canvas_animation() override
{
Canvas::I->anim_update();
}
void reload_animation_layers() override
{
panel_.load_layers();
}
void mark_unsaved() override
{
Canvas::I->m_unsaved = true;
}
private:
NodePanelAnimation& panel_;
Layer* layer_ = nullptr;
};
LegacyAnimationServices services(*this, layer);
const auto status = pp::app::execute_animation_operation_plan(plan, services);
if (!status.ok())
LOG("Animation panel action failed: %s", status.message);
}
void NodePanelAnimation::init_controls() void NodePanelAnimation::init_controls()
{ {
m_layers_container = find<NodeScroll>("layers"); m_layers_container = find<NodeScroll>("layers");
@@ -51,13 +130,7 @@ void NodePanelAnimation::init_controls()
Canvas::I->m_anim_frame); Canvas::I->m_anim_frame);
if (!plan) if (!plan)
return; return;
Canvas::I->layer().add_frame(); execute_animation_plan(plan.value());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_update();
if (plan.value().reloads_animation_layers)
load_layers();
}; };
btn_duplicate->on_click = [this](Node*) { btn_duplicate->on_click = [this](Node*) {
if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id))
@@ -67,13 +140,7 @@ void NodePanelAnimation::init_controls()
m_selected_frame_index); m_selected_frame_index);
if (!plan) if (!plan)
return; return;
layer->duplicate_frame(plan.value().selected_frame); execute_animation_plan(plan.value(), layer.get());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_update();
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
btn_remove->on_click = [this](Node*) { btn_remove->on_click = [this](Node*) {
@@ -84,24 +151,12 @@ void NodePanelAnimation::init_controls()
m_selected_frame_index); m_selected_frame_index);
if (!plan) if (!plan)
return; return;
layer->remove_frame(plan.value().selected_frame); execute_animation_plan(plan.value(), layer.get());
m_selected_frame_index = plan.value().target_frame;
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_goto_frame(plan.value().target_frame);
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
btn_up->on_click = [this](Node*) { btn_up->on_click = [this](Node*) {
if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id))
{ {
const auto index_status = pp::app::validate_animation_frame_index(
layer->frames_count(),
m_selected_frame_index);
if (!index_status.ok())
return;
const auto plan = pp::app::plan_animation_adjust_duration( const auto plan = pp::app::plan_animation_adjust_duration(
layer->frames_count(), layer->frames_count(),
m_selected_frame_index, m_selected_frame_index,
@@ -109,23 +164,12 @@ void NodePanelAnimation::init_controls()
1); 1);
if (!plan) if (!plan)
return; return;
layer->set_frame_duration(plan.value().selected_frame, plan.value().frame_duration); execute_animation_plan(plan.value(), layer.get());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_update();
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
btn_down->on_click = [this](Node*) { btn_down->on_click = [this](Node*) {
if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id))
{ {
const auto index_status = pp::app::validate_animation_frame_index(
layer->frames_count(),
m_selected_frame_index);
if (!index_status.ok())
return;
const auto plan = pp::app::plan_animation_adjust_duration( const auto plan = pp::app::plan_animation_adjust_duration(
layer->frames_count(), layer->frames_count(),
m_selected_frame_index, m_selected_frame_index,
@@ -133,13 +177,7 @@ void NodePanelAnimation::init_controls()
-1); -1);
if (!plan) if (!plan)
return; return;
layer->set_frame_duration(plan.value().selected_frame, plan.value().frame_duration); execute_animation_plan(plan.value(), layer.get());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_update();
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
btn_left->on_click = [this](Node*) { btn_left->on_click = [this](Node*) {
@@ -153,13 +191,7 @@ void NodePanelAnimation::init_controls()
-1); -1);
if (!plan) if (!plan)
return; return;
m_selected_frame_index = layer->move_frame_offset(plan.value().selected_frame, plan.value().move_offset); execute_animation_plan(plan.value(), layer.get());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_goto_frame(m_selected_frame_index);
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
btn_right->on_click = [this](Node*) { btn_right->on_click = [this](Node*) {
@@ -173,13 +205,7 @@ void NodePanelAnimation::init_controls()
1); 1);
if (!plan) if (!plan)
return; return;
m_selected_frame_index = layer->move_frame_offset(plan.value().selected_frame, plan.value().move_offset); execute_animation_plan(plan.value(), layer.get());
if (plan.value().marks_unsaved)
Canvas::I->m_unsaved = true;
if (plan.value().updates_canvas_animation)
Canvas::I->anim_goto_frame(m_selected_frame_index);
if (plan.value().reloads_animation_layers)
load_layers();
} }
}; };
@@ -187,9 +213,7 @@ void NodePanelAnimation::init_controls()
const auto plan = pp::app::plan_animation_onion_size(m_onion->get_int()); const auto plan = pp::app::plan_animation_onion_size(m_onion->get_int());
if (!plan) if (!plan)
return; return;
m_timeline->m_onion_size = plan.value().onion_size; execute_animation_plan(plan.value());
if (plan.value().updates_canvas_animation)
Canvas::I->anim_update();
}; };
m_timeline->on_frame_changed = [this] (NodeAnimationTimeline* target, int frame) { m_timeline->on_frame_changed = [this] (NodeAnimationTimeline* target, int frame) {
@@ -197,29 +221,20 @@ void NodePanelAnimation::init_controls()
if (!plan) if (!plan)
return; return;
LOG("goto frame %d", plan.value().target_frame); LOG("goto frame %d", plan.value().target_frame);
if (plan.value().updates_canvas_animation) execute_animation_plan(plan.value());
Canvas::I->anim_goto_frame(plan.value().target_frame);
if (plan.value().reloads_animation_layers)
load_layers();
}; };
btn_next->on_click = [this] (Node* target) { btn_next->on_click = [this] (Node* target) {
const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, 1); const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, 1);
if (!plan) if (!plan)
return; return;
if (plan.value().updates_canvas_animation) execute_animation_plan(plan.value());
Canvas::I->anim_goto_frame(plan.value().target_frame);
if (plan.value().reloads_animation_layers)
load_layers();
}; };
btn_prev->on_click = [this](Node* target) { btn_prev->on_click = [this](Node* target) {
const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, -1); const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, -1);
if (!plan) if (!plan)
return; return;
if (plan.value().updates_canvas_animation) execute_animation_plan(plan.value());
Canvas::I->anim_goto_frame(plan.value().target_frame);
if (plan.value().reloads_animation_layers)
load_layers();
}; };
btn_play->on_click = [this] (Node* target) { btn_play->on_click = [this] (Node* target) {
static auto mode = Canvas::I->m_current_mode; static auto mode = Canvas::I->m_current_mode;

View File

@@ -7,6 +7,12 @@
#include "node_button_custom.h" #include "node_button_custom.h"
#include "node_combobox.h" #include "node_combobox.h"
class Layer;
namespace pp::app {
struct DocumentAnimationOperationPlan;
}
class NodeAnimationFrame : public NodeButtonCustom class NodeAnimationFrame : public NodeButtonCustom
{ {
public: public:
@@ -65,6 +71,8 @@ class NodePanelAnimation : public Node
int m_selected_frame_index = -1; int m_selected_frame_index = -1;
uint32_t m_selected_frame_layer_id = 0; uint32_t m_selected_frame_layer_id = 0;
float m_playback_timer = 0; float m_playback_timer = 0;
void execute_animation_plan(const pp::app::DocumentAnimationOperationPlan& plan, Layer* layer = nullptr);
public: public:
using this_class = NodePanelAnimation; using this_class = NodePanelAnimation;
using parent = Node; using parent = Node;

View File

@@ -2,6 +2,7 @@
#include "test_harness.h" #include "test_harness.h"
#include <limits> #include <limits>
#include <string>
#define PP_REQUIRE(harness, expression) \ #define PP_REQUIRE(harness, expression) \
do { \ do { \
@@ -14,6 +15,97 @@
namespace { namespace {
class FakeDocumentAnimationServices final : public pp::app::DocumentAnimationServices {
public:
void add_frame() override
{
adds += 1;
call_order += "add;";
}
void duplicate_frame(int selected_frame) override
{
duplicates += 1;
last_selected_frame = selected_frame;
call_order += "duplicate;";
}
void remove_frame(int selected_frame, int target_frame) override
{
removes += 1;
last_selected_frame = selected_frame;
last_target_frame = target_frame;
call_order += "remove;";
}
void set_frame_duration(int selected_frame, int duration) override
{
duration_sets += 1;
last_selected_frame = selected_frame;
last_duration = duration;
call_order += "duration;";
}
int move_frame(int selected_frame, int move_offset) override
{
moves += 1;
last_selected_frame = selected_frame;
last_move_offset = move_offset;
call_order += "move;";
return move_result;
}
void goto_frame(int target_frame) override
{
gotos += 1;
last_target_frame = target_frame;
call_order += "goto;";
}
void set_onion_size(int onion_size) override
{
onion_sets += 1;
last_onion_size = onion_size;
call_order += "onion;";
}
void update_canvas_animation() override
{
canvas_updates += 1;
call_order += "update;";
}
void reload_animation_layers() override
{
reloads += 1;
call_order += "reload;";
}
void mark_unsaved() override
{
unsaved_marks += 1;
call_order += "unsaved;";
}
int adds = 0;
int duplicates = 0;
int removes = 0;
int duration_sets = 0;
int moves = 0;
int gotos = 0;
int onion_sets = 0;
int canvas_updates = 0;
int reloads = 0;
int unsaved_marks = 0;
int last_selected_frame = -1;
int last_target_frame = -1;
int last_duration = -1;
int last_move_offset = 0;
int last_onion_size = -1;
int move_result = 0;
std::string call_order;
};
void add_duplicate_and_remove_validate_frame_bounds(pp::tests::Harness& harness) void add_duplicate_and_remove_validate_frame_bounds(pp::tests::Harness& harness)
{ {
const auto add = pp::app::plan_animation_add_frame(2, 0); const auto add = pp::app::plan_animation_add_frame(2, 0);
@@ -120,6 +212,107 @@ void onion_size_updates_canvas_without_document_mutation(pp::tests::Harness& har
PP_EXPECT(harness, !pp::app::plan_animation_onion_size(-1)); PP_EXPECT(harness, !pp::app::plan_animation_onion_size(-1));
} }
void executor_dispatches_mutating_frame_operations(pp::tests::Harness& harness)
{
FakeDocumentAnimationServices services;
const auto add = pp::app::plan_animation_add_frame(2, 0);
PP_REQUIRE(harness, add);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(add.value(), services).ok());
const auto duplicate = pp::app::plan_animation_duplicate_frame(3, 1);
PP_REQUIRE(harness, duplicate);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(duplicate.value(), services).ok());
const auto remove = pp::app::plan_animation_remove_frame(3, 2);
PP_REQUIRE(harness, remove);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(remove.value(), services).ok());
PP_EXPECT(harness, services.adds == 1);
PP_EXPECT(harness, services.duplicates == 1);
PP_EXPECT(harness, services.removes == 1);
PP_EXPECT(harness, services.unsaved_marks == 3);
PP_EXPECT(harness, services.canvas_updates == 2);
PP_EXPECT(harness, services.gotos == 1);
PP_EXPECT(harness, services.reloads == 3);
PP_EXPECT(harness, services.last_selected_frame == 2);
PP_EXPECT(harness, services.last_target_frame == 1);
PP_EXPECT(
harness,
services.call_order == "add;unsaved;update;reload;duplicate;unsaved;update;reload;remove;unsaved;goto;reload;");
}
void executor_dispatches_timeline_and_parameter_operations(pp::tests::Harness& harness)
{
FakeDocumentAnimationServices services;
services.move_result = 2;
const auto duration = pp::app::plan_animation_adjust_duration(3, 1, 4, 1);
PP_REQUIRE(harness, duration);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(duration.value(), services).ok());
const auto duration_noop = pp::app::plan_animation_adjust_duration(3, 1, 1, -1);
PP_REQUIRE(harness, duration_noop);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(duration_noop.value(), services).ok());
const auto move = pp::app::plan_animation_move_frame(3, 1, 1);
PP_REQUIRE(harness, move);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(move.value(), services).ok());
const auto next = pp::app::plan_animation_step_frame(5, 4, 1);
PP_REQUIRE(harness, next);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(next.value(), services).ok());
const auto onion = pp::app::plan_animation_onion_size(3);
PP_REQUIRE(harness, onion);
PP_EXPECT(harness, pp::app::execute_animation_operation_plan(onion.value(), services).ok());
PP_EXPECT(harness, services.duration_sets == 1);
PP_EXPECT(harness, services.last_duration == 5);
PP_EXPECT(harness, services.moves == 1);
PP_EXPECT(harness, services.last_move_offset == 1);
PP_EXPECT(harness, services.gotos == 2);
PP_EXPECT(harness, services.last_target_frame == 0);
PP_EXPECT(harness, services.onion_sets == 1);
PP_EXPECT(harness, services.last_onion_size == 3);
PP_EXPECT(harness, services.unsaved_marks == 2);
PP_EXPECT(harness, services.canvas_updates == 2);
PP_EXPECT(harness, services.reloads == 3);
PP_EXPECT(
harness,
services.call_order == "duration;unsaved;update;reload;move;unsaved;goto;reload;goto;reload;onion;update;");
}
void executor_rejects_malformed_animation_plans(pp::tests::Harness& harness)
{
FakeDocumentAnimationServices services;
auto add = pp::app::plan_animation_add_frame(2, 0).value();
add.marks_unsaved = false;
PP_EXPECT(harness, !pp::app::execute_animation_operation_plan(add, services).ok());
auto remove = pp::app::plan_animation_remove_frame(2, 1).value();
remove.frame_count = 1;
PP_EXPECT(harness, !pp::app::execute_animation_operation_plan(remove, services).ok());
auto duration = pp::app::plan_animation_adjust_duration(2, 1, 4, 1).value();
duration.frame_duration = 0;
PP_EXPECT(harness, !pp::app::execute_animation_operation_plan(duration, services).ok());
auto move = pp::app::plan_animation_move_frame(3, 1, 1).value();
move.move_offset = 0;
PP_EXPECT(harness, !pp::app::execute_animation_operation_plan(move, services).ok());
auto go = pp::app::plan_animation_goto_frame(3, 1).value();
go.target_frame = 3;
PP_EXPECT(harness, !pp::app::execute_animation_operation_plan(go, services).ok());
PP_EXPECT(harness, services.adds == 0);
PP_EXPECT(harness, services.duration_sets == 0);
PP_EXPECT(harness, services.gotos == 0);
PP_EXPECT(harness, services.call_order.empty());
}
} // namespace } // namespace
int main() int main()
@@ -129,5 +322,8 @@ int main()
harness.run("duration plans clamp floor and reject overflow", duration_plans_clamp_floor_and_reject_overflow); harness.run("duration plans clamp floor and reject overflow", duration_plans_clamp_floor_and_reject_overflow);
harness.run("move and timeline plans handle edges", move_and_timeline_plans_handle_edges); harness.run("move and timeline plans handle edges", move_and_timeline_plans_handle_edges);
harness.run("onion size updates canvas without document mutation", onion_size_updates_canvas_without_document_mutation); harness.run("onion size updates canvas without document mutation", onion_size_updates_canvas_without_document_mutation);
harness.run("executor dispatches mutating frame operations", executor_dispatches_mutating_frame_operations);
harness.run("executor dispatches timeline and parameter operations", executor_dispatches_timeline_and_parameter_operations);
harness.run("executor rejects malformed animation plans", executor_rejects_malformed_animation_plans);
return harness.finish(); return harness.finish();
} }