#pragma once #include "foundation/result.h" #include #include #include #include #include #include #include #include namespace pp::app { inline constexpr int document_animation_default_frame_duration = 1; enum class DocumentAnimationOperation { add_frame, duplicate_frame, remove_frame, adjust_duration, move_frame, select_frame, goto_frame, goto_next, goto_previous, playback_step, toggle_playback, set_onion_size, }; enum class DocumentAnimationPanelAction { goto_frame, next_frame, previous_frame, playback_step, toggle_playback, }; struct DocumentAnimationPanelState { int total_duration = 1; int current_frame = 0; bool playback_active = false; }; struct DocumentAnimationOperationPlan { DocumentAnimationOperation operation = DocumentAnimationOperation::goto_frame; int frame_count = 1; int current_frame = 0; int selected_frame = 0; int target_frame = 0; int frame_duration = document_animation_default_frame_duration; int duration_delta = 0; int move_offset = 0; int onion_size = 1; int layer_index = 0; std::uint32_t layer_id = 0; int playback_idle_ms = 100; bool requires_selected_frame = false; bool mutates_document = false; bool reloads_animation_layers = false; bool updates_canvas_animation = false; bool marks_unsaved = false; bool playback_was_active = false; bool playback_active = false; bool resets_playback_timer = false; }; struct DocumentAnimationOnionFrameRange { int frame_count = 1; int current_frame = 0; int onion_size = 0; int first_frame = 0; int last_frame = 0; }; inline constexpr float document_animation_timeline_frame_width = 35.0F; struct DocumentAnimationTimelineScrubPlan { int total_duration = 1; float cursor_x = 0.0F; float frame_width = document_animation_timeline_frame_width; int target_frame = 0; }; struct DocumentAnimationLayerInput { int layer_index = 0; std::uint32_t layer_id = 0; std::string name; bool visible = true; std::vector frame_durations; }; struct DocumentAnimationFrameView { int frame_index = 0; int duration = document_animation_default_frame_duration; bool selected = false; }; struct DocumentAnimationLayerView { int layer_index = 0; std::uint32_t layer_id = 0; std::string name; bool visible = true; bool current = false; std::vector frames; }; struct DocumentAnimationPanelView { int total_duration = 1; int current_frame = 0; int onion_size = 0; std::uint32_t selected_layer_id = 0; int selected_frame = -1; bool has_selected_frame = false; std::vector layers; }; 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 select_frame(std::uint32_t layer_id, int layer_index, int selected_frame) = 0; virtual void select_layer(int layer_index) = 0; virtual void goto_frame(int target_frame) = 0; virtual void set_timeline_frame(int target_frame) = 0; virtual void set_onion_size(int onion_size) = 0; virtual void capture_playback_restore_mode() = 0; virtual void enter_playback_camera_mode() = 0; virtual void restore_playback_canvas_mode() = 0; virtual void set_playback_active(bool active) = 0; virtual void reset_playback_timer() = 0; virtual void set_playback_idle_ms(int idle_ms) = 0; virtual void update_canvas_animation() = 0; virtual void update_frame_status() = 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 { if (frame_count <= 0) { return pp::foundation::Status::invalid_argument("animation layer must contain at least one frame"); } return pp::foundation::Status::success(); } [[nodiscard]] inline pp::foundation::Status validate_animation_frame_index( int frame_count, int index) noexcept { const auto count_status = validate_animation_frame_count(frame_count); if (!count_status.ok()) { return count_status; } if (index < 0 || index >= frame_count) { return pp::foundation::Status::out_of_range("animation frame index is outside the layer"); } return pp::foundation::Status::success(); } [[nodiscard]] inline pp::foundation::Status validate_animation_frame_duration(int duration) noexcept { if (duration < 1) { return pp::foundation::Status::invalid_argument("animation frame duration must be at least 1"); } return pp::foundation::Status::success(); } [[nodiscard]] inline pp::foundation::Result plan_animation_panel_view( const std::vector& layers, int total_duration, int current_layer_index, int current_frame, std::uint32_t selected_layer_id, int selected_frame, int onion_size) { if (layers.empty()) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation panel requires at least one layer")); } const auto timeline_status = validate_animation_frame_index(total_duration, current_frame); if (!timeline_status.ok()) { return pp::foundation::Result::failure(timeline_status); } if (current_layer_index < 0 || current_layer_index >= static_cast(layers.size())) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("current animation layer index is outside the document")); } if (onion_size < 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation onion size must not be negative")); } DocumentAnimationPanelView view; view.total_duration = total_duration; view.current_frame = current_frame; view.onion_size = onion_size; view.selected_layer_id = selected_layer_id; view.selected_frame = selected_frame; view.layers.reserve(layers.size()); for (std::size_t i = 0; i < layers.size(); ++i) { const auto& input = layers[i]; if (input.layer_index < 0) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("animation layer index must not be negative")); } if (input.frame_durations.empty()) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation layer must contain at least one frame")); } DocumentAnimationLayerView layer; layer.layer_index = input.layer_index; layer.layer_id = input.layer_id; layer.name = input.name; layer.visible = input.visible; layer.current = input.layer_index == current_layer_index; layer.frames.reserve(input.frame_durations.size()); for (std::size_t frame_index = 0; frame_index < input.frame_durations.size(); ++frame_index) { const int duration = input.frame_durations[frame_index]; const auto duration_status = validate_animation_frame_duration(duration); if (!duration_status.ok()) { return pp::foundation::Result::failure(duration_status); } const bool selected = selected_frame >= 0 && input.layer_id == selected_layer_id && static_cast(frame_index) == selected_frame; view.has_selected_frame = view.has_selected_frame || selected; layer.frames.push_back(DocumentAnimationFrameView { .frame_index = static_cast(frame_index), .duration = duration, .selected = selected, }); } view.layers.push_back(std::move(layer)); } return pp::foundation::Result::success(std::move(view)); } [[nodiscard]] inline pp::foundation::Result plan_animation_onion_frame_range( int frame_count, int current_frame, int onion_size) { const auto index_status = validate_animation_frame_index(frame_count, current_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } if (onion_size < 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation onion size must not be negative")); } const auto first = std::max( static_cast(current_frame) - static_cast(onion_size), 0); const auto last = std::min( static_cast(current_frame) + static_cast(onion_size), static_cast(frame_count) - 1); return pp::foundation::Result::success( DocumentAnimationOnionFrameRange { .frame_count = frame_count, .current_frame = current_frame, .onion_size = onion_size, .first_frame = static_cast(first), .last_frame = static_cast(last), }); } [[nodiscard]] inline pp::foundation::Result plan_animation_timeline_scrub( int total_duration, float cursor_x, float frame_width = document_animation_timeline_frame_width) { if (total_duration <= 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation timeline duration must be greater than zero")); } if (!std::isfinite(cursor_x)) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation timeline cursor position must be finite")); } if (!std::isfinite(frame_width) || frame_width <= 0.0F) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation timeline frame width must be positive and finite")); } const auto raw_frame = static_cast(std::floor(cursor_x / frame_width)); const auto target_frame = std::clamp(raw_frame, 0, total_duration - 1); return pp::foundation::Result::success( DocumentAnimationTimelineScrubPlan { .total_duration = total_duration, .cursor_x = cursor_x, .frame_width = frame_width, .target_frame = static_cast(target_frame), }); } [[nodiscard]] inline float animation_onion_frame_alpha( const DocumentAnimationOnionFrameRange& range, int frame) noexcept { if (frame < range.first_frame || frame > range.last_frame) { return 0.0f; } const int distance = frame >= range.current_frame ? frame - range.current_frame : range.current_frame - frame; if (distance > range.onion_size) { return 0.0f; } return 1.0f - static_cast(distance) / static_cast(range.onion_size + 1); } [[nodiscard]] inline pp::foundation::Result plan_animation_add_frame( int frame_count, int current_frame) { const auto count_status = validate_animation_frame_count(frame_count); if (!count_status.ok()) { return pp::foundation::Result::failure(count_status); } if (current_frame < 0) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("current animation frame must not be negative")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::add_frame; plan.frame_count = frame_count; plan.current_frame = current_frame; plan.selected_frame = frame_count; plan.target_frame = current_frame; plan.mutates_document = true; plan.reloads_animation_layers = true; plan.updates_canvas_animation = true; plan.marks_unsaved = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_duplicate_frame( int frame_count, int selected_frame) { const auto index_status = validate_animation_frame_index(frame_count, selected_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::duplicate_frame; plan.frame_count = frame_count; plan.selected_frame = selected_frame; plan.target_frame = selected_frame + 1; plan.requires_selected_frame = true; plan.mutates_document = true; plan.reloads_animation_layers = true; plan.updates_canvas_animation = true; plan.marks_unsaved = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_remove_frame( int frame_count, int selected_frame) { const auto index_status = validate_animation_frame_index(frame_count, selected_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } if (frame_count <= 1) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation layer must keep at least one frame")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::remove_frame; plan.frame_count = frame_count; plan.selected_frame = selected_frame; plan.target_frame = std::min(selected_frame, frame_count - 2); plan.requires_selected_frame = true; plan.mutates_document = true; plan.reloads_animation_layers = true; plan.updates_canvas_animation = true; plan.marks_unsaved = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_adjust_duration( int frame_count, int selected_frame, int current_duration, int delta) { const auto index_status = validate_animation_frame_index(frame_count, selected_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } const auto duration_status = validate_animation_frame_duration(current_duration); if (!duration_status.ok()) { return pp::foundation::Result::failure(duration_status); } if (delta == 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation frame duration delta must not be zero")); } if (delta > 0 && current_duration > std::numeric_limits::max() - delta) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("animation frame duration would overflow")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::adjust_duration; plan.frame_count = frame_count; plan.selected_frame = selected_frame; plan.target_frame = selected_frame; plan.frame_duration = std::max(current_duration + delta, 1); plan.duration_delta = delta; plan.requires_selected_frame = true; plan.mutates_document = plan.frame_duration != current_duration; plan.reloads_animation_layers = plan.mutates_document; plan.updates_canvas_animation = plan.mutates_document; plan.marks_unsaved = plan.mutates_document; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_move_frame( int frame_count, int selected_frame, int offset) { const auto index_status = validate_animation_frame_index(frame_count, selected_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } if (offset == 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation frame move offset must not be zero")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::move_frame; plan.frame_count = frame_count; plan.selected_frame = selected_frame; const auto unclamped_target = static_cast(selected_frame) + static_cast(offset); plan.target_frame = static_cast(std::clamp(unclamped_target, 0, frame_count - 1)); plan.move_offset = offset; plan.requires_selected_frame = true; plan.mutates_document = plan.target_frame != selected_frame; plan.reloads_animation_layers = true; plan.updates_canvas_animation = true; plan.marks_unsaved = plan.mutates_document; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_select_frame( int frame_count, int layer_index, std::uint32_t layer_id, int selected_frame) { const auto index_status = validate_animation_frame_index(frame_count, selected_frame); if (!index_status.ok()) { return pp::foundation::Result::failure(index_status); } if (layer_index < 0) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("animation layer index must not be negative")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::select_frame; plan.frame_count = frame_count; plan.selected_frame = selected_frame; plan.target_frame = selected_frame; plan.layer_index = layer_index; plan.layer_id = layer_id; plan.requires_selected_frame = true; plan.updates_canvas_animation = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_goto_frame( int total_duration, int frame) { if (total_duration <= 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation duration must be greater than zero")); } if (frame < 0 || frame >= total_duration) { return pp::foundation::Result::failure( pp::foundation::Status::out_of_range("animation timeline frame is outside the document")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::goto_frame; plan.frame_count = total_duration; plan.current_frame = frame; plan.target_frame = frame; plan.reloads_animation_layers = true; plan.updates_canvas_animation = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_step_frame( int total_duration, int current_frame, int offset) { const auto current_status = plan_animation_goto_frame(total_duration, current_frame); if (!current_status) { return pp::foundation::Result::failure(current_status.status()); } if (offset == 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation frame step offset must not be zero")); } DocumentAnimationOperationPlan plan; plan.operation = offset > 0 ? DocumentAnimationOperation::goto_next : DocumentAnimationOperation::goto_previous; plan.frame_count = total_duration; plan.current_frame = current_frame; auto target = (static_cast(current_frame) + static_cast(offset)) % static_cast(total_duration); if (target < 0) { target += total_duration; } plan.target_frame = static_cast(target); plan.updates_canvas_animation = true; plan.reloads_animation_layers = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_playback_step( int total_duration, int current_frame, int offset) { const auto step = plan_animation_step_frame(total_duration, current_frame, offset); if (!step) { return pp::foundation::Result::failure(step.status()); } auto plan = step.value(); plan.operation = DocumentAnimationOperation::playback_step; plan.move_offset = offset; plan.reloads_animation_layers = false; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_playback_toggle( bool playback_active) { DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::toggle_playback; plan.playback_was_active = playback_active; plan.playback_active = !playback_active; plan.playback_idle_ms = playback_active ? 100 : 10; plan.resets_playback_timer = !playback_active; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_onion_size(int onion_size) { if (onion_size < 0) { return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("animation onion size must not be negative")); } DocumentAnimationOperationPlan plan; plan.operation = DocumentAnimationOperation::set_onion_size; plan.onion_size = onion_size; plan.updates_canvas_animation = true; return pp::foundation::Result::success(plan); } [[nodiscard]] inline pp::foundation::Result plan_animation_panel_action( DocumentAnimationPanelAction action, const DocumentAnimationPanelState& state, int target_frame = 0) { switch (action) { case DocumentAnimationPanelAction::goto_frame: return plan_animation_goto_frame(state.total_duration, target_frame); case DocumentAnimationPanelAction::next_frame: return plan_animation_step_frame(state.total_duration, state.current_frame, 1); case DocumentAnimationPanelAction::previous_frame: return plan_animation_step_frame(state.total_duration, state.current_frame, -1); case DocumentAnimationPanelAction::playback_step: return plan_animation_playback_step(state.total_duration, state.current_frame, 1); case DocumentAnimationPanelAction::toggle_playback: return plan_animation_playback_toggle(state.playback_active); } return pp::foundation::Result::failure( pp::foundation::Status::invalid_argument("unknown animation panel action")); } [[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::select_frame: if (!plan.requires_selected_frame || !plan.updates_canvas_animation || plan.layer_index < 0) { return pp::foundation::Status::invalid_argument("animation frame select plan has invalid state"); } { 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_index(plan.frame_count, plan.target_frame); case DocumentAnimationOperation::goto_frame: case DocumentAnimationOperation::goto_next: case DocumentAnimationOperation::goto_previous: case DocumentAnimationOperation::playback_step: if (!plan.updates_canvas_animation) { return pp::foundation::Status::invalid_argument("animation goto plan must update canvas animation"); } if (plan.operation == DocumentAnimationOperation::playback_step && plan.move_offset == 0) { return pp::foundation::Status::invalid_argument("animation playback step offset must not be zero"); } return validate_animation_frame_index(plan.frame_count, plan.target_frame); case DocumentAnimationOperation::toggle_playback: if (plan.playback_active == plan.playback_was_active) { return pp::foundation::Status::invalid_argument("animation playback toggle must change state"); } if (plan.playback_idle_ms <= 0) { return pp::foundation::Status::invalid_argument("animation playback idle interval must be positive"); } if (plan.playback_active && !plan.resets_playback_timer) { return pp::foundation::Status::invalid_argument("animation playback start must reset timer"); } return pp::foundation::Status::success(); 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::select_frame: services.select_frame(plan.layer_id, plan.layer_index, plan.selected_frame); if (plan.updates_canvas_animation) { services.goto_frame(plan.target_frame); } services.select_layer(plan.layer_index); return pp::foundation::Status::success(); case DocumentAnimationOperation::playback_step: services.goto_frame(plan.target_frame); services.set_timeline_frame(plan.target_frame); services.update_frame_status(); return pp::foundation::Status::success(); case DocumentAnimationOperation::toggle_playback: if (plan.playback_active) { services.capture_playback_restore_mode(); services.enter_playback_camera_mode(); if (plan.resets_playback_timer) { services.reset_playback_timer(); } } else { services.restore_playback_canvas_mode(); } services.set_playback_active(plan.playback_active); services.set_playback_idle_ms(plan.playback_idle_ms); 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