Source code for simvx.editor.panels.animation

"""Animation Timeline Panel -- Keyframe animation editor with visual timeline.

Provides a Godot-style animation dope sheet for editing AnimationPlayer
clips.  Users can select tracks, insert/move/delete keyframes, scrub
the playhead, and control playback -- all with full undo/redo support.

Layout:
    +----------------------------------------------------+
    | [clip v] Player   |  << Play Pause Stop >>  Loop   |
    |---------+--------------------------------------------|
    | ruler   | 0    0.25   0.5   0.75   1.0   1.25  ... |
    |---------+--------------------------------------------|
    | pos.x x | <>      <>           <>                   |
    | pos.y x | <>               <>                       |
    | rot   x | <>  <>      <>           <>               |
    +----------------------------------------------------+

The panel is the canonical public surface (``AnimationPanel``).  Two private
sibling helpers own cohesive responsibility groups and are injected with a
back-reference to the panel:

* :class:`_TimelineRenderer` -- all dope-sheet drawing (header, ruler, track
  rows + keyframe diamonds, playhead, context menu).
* :class:`_TimelineInput` -- mouse/keyboard/scroll routing, header/track hit
  testing, and the context-menu interaction model.

Coordinate conversion, clip/selection model, transport, and the undoable
keyframe/track operations remain on the panel itself (they mutate panel state
and push to the editor undo stack).  Helpers read and drive that state through
the back-reference; the panel exposes thin delegators for every method the
helpers moved so its external surface is unchanged.
"""

from typing import Any

from simvx.core import (
    AnimationClip,
    AnimationPlayer,
    CallableCommand,
    Control,
    Signal,
    Track,
    Vec2,
)

from ._animation_constants import _BG, _HEADER_HEIGHT, _LABEL_WIDTH, _RULER_HEIGHT, _SPEED_OPTIONS, _ZOOM_DEFAULT
from ._animation_input import _TimelineInput
from ._animation_timeline import _TimelineRenderer

__all__ = ["AnimationPanel"]


# ============================================================================
# AnimationPanel
# ============================================================================

[docs] class AnimationPanel(Control): """Animation timeline panel for keyframe editing. Displays an AnimationPlayer's clips as a dope-sheet timeline. Supports playback controls, keyframe insertion/deletion/dragging, and interactive scrubbing -- all routed through the editor undo stack. Drawing and input routing are delegated to two private sibling helpers (:class:`_TimelineRenderer`, :class:`_TimelineInput`); the clip/selection model, coordinate maths, transport, and undoable operations live here. Args: editor_state: The central State instance. """ def __init__(self, editor_state, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = _BG self.size = Vec2(800, 250) # The AnimationPlayer we are editing (if any) self._player: AnimationPlayer | None = None self._player_node = None # the Node that owns the player # View state self._zoom: float = _ZOOM_DEFAULT # pixels per second self._scroll_x: float = 0.0 # horizontal scroll in pixels self._scroll_y: float = 0.0 # vertical track scroll # Selection state self._selected_track: str | None = None self._selected_keyframe: tuple[str, int] | None = None # (track_name, kf_index) self._clip_index: int = 0 # which clip is active in the dropdown # Drag state self._dragging_playhead: bool = False self._dragging_keyframe: bool = False self._drag_kf_original_time: float = 0.0 # Context menu self._context_menu_visible: bool = False self._context_menu_pos: tuple[float, float] = (0.0, 0.0) self._context_menu_items: list[tuple[str, Any]] = [] # Speed cycle index self._speed_index: int = 2 # default 1.0x # Playback tracking self._last_clip_name: str | None = None # Composition helpers (own drawing + input; injected with self) self._renderer = _TimelineRenderer(self) self._input = _TimelineInput(self) # Signal emitted when the panel wants the editor to refresh self.timeline_changed = Signal() # ==================================================================== # Lifecycle # ====================================================================
[docs] def on_ready(self): """Connect to editor state signals.""" if hasattr(self.state, 'selection_changed'): self.state.selection_changed.connect(self._on_selection_changed)
[docs] def on_process(self, dt: float): """Update playhead position during playback."""
# AnimationPlayer._process is called elsewhere; we just track it here # ==================================================================== # Selection handling # ==================================================================== def _on_selection_changed(self): """When editor selection changes, search for an AnimationPlayer.""" node = self.state.selection.primary if hasattr(self.state.selection, 'primary') else None if node is None: self._set_player(None, None) return player = self._find_animation_player(node) if player is not None: self._set_player(player, node) else: self._set_player(None, None) def _find_animation_player(self, node) -> AnimationPlayer | None: """Search node and its children for an AnimationPlayer instance. Checks if the node itself is an AnimationPlayer, or if any attribute on the node is an AnimationPlayer, or recurses into children. """ if isinstance(node, AnimationPlayer): return node # Check attributes for embedded AnimationPlayer for attr_name in dir(node): if attr_name.startswith('_'): continue try: val = getattr(node, attr_name) if isinstance(val, AnimationPlayer): return val except (AttributeError, TypeError): continue # Recurse children if hasattr(node, 'children'): for child in node.children: result = self._find_animation_player(child) if result is not None: return result return None def _set_player(self, player: AnimationPlayer | None, node): """Set the active animation player.""" self._player = player self._player_node = node self._selected_track = None self._selected_keyframe = None self._clip_index = 0 self._context_menu_visible = False # ==================================================================== # Active clip helpers # ==================================================================== def _get_clip_names(self) -> list[str]: """Return sorted list of clip names from the active player.""" if not self._player: return [] return sorted(self._player.clips.keys()) def _get_active_clip(self) -> AnimationClip | None: """Get the currently displayed clip.""" if not self._player: return None names = self._get_clip_names() if not names: return None idx = max(0, min(self._clip_index, len(names) - 1)) name = names[idx] return self._player.clips.get(name) def _get_active_clip_name(self) -> str | None: """Get name of the currently displayed clip.""" names = self._get_clip_names() if not names: return None idx = max(0, min(self._clip_index, len(names) - 1)) return names[idx] # ==================================================================== # Coordinate conversion # ==================================================================== def _time_to_x(self, time: float) -> float: """Convert a time value (seconds) to x pixel offset within the keyframe area (relative to the left edge of the keyframe region).""" return time * self._zoom - self._scroll_x def _x_to_time(self, x: float) -> float: """Convert an x pixel offset (within keyframe area) to a time value.""" return (x + self._scroll_x) / self._zoom def _keyframe_area_left(self) -> float: """X coordinate where the keyframe area starts (after track labels).""" gx, _, _, _ = self.get_global_rect() return gx + _LABEL_WIDTH def _header_rect(self) -> tuple[float, float, float, float]: """Global rect for the header bar.""" gx, gy, gw, _ = self.get_global_rect() return (gx, gy, gw, _HEADER_HEIGHT) def _ruler_rect(self) -> tuple[float, float, float, float]: """Global rect for the timeline ruler.""" gx, gy, gw, _ = self.get_global_rect() return (gx + _LABEL_WIDTH, gy + _HEADER_HEIGHT, gw - _LABEL_WIDTH, _RULER_HEIGHT) def _tracks_rect(self) -> tuple[float, float, float, float]: """Global rect for the scrollable track area.""" gx, gy, gw, gh = self.get_global_rect() top = gy + _HEADER_HEIGHT + _RULER_HEIGHT return (gx, top, gw, gh - _HEADER_HEIGHT - _RULER_HEIGHT) # ==================================================================== # Drawing (delegated to _TimelineRenderer) # ====================================================================
[docs] def on_draw(self, renderer): self._renderer.draw(renderer)
def _draw_empty_message(self, renderer, x, y, w, h): """Draw placeholder when no AnimationPlayer is selected.""" self._renderer.draw_empty_message(renderer, x, y, w, h) def _draw_header(self, renderer, gx, gy, gw): """Draw the top header bar with playback controls.""" self._renderer.draw_header(renderer, gx, gy, gw) def _draw_ruler(self, renderer, clip, gx, gy, gw): """Draw the timeline ruler with tick marks.""" self._renderer.draw_ruler(renderer, clip, gx, gy, gw) def _draw_tracks(self, renderer, clip, gx, gy, gw, gh): """Draw the track rows with keyframe diamonds.""" self._renderer.draw_tracks(renderer, clip, gx, gy, gw, gh) def _draw_playhead(self, renderer, clip, gx, gy, gw, gh): """Draw the vertical playhead line at current_time.""" self._renderer.draw_playhead(renderer, clip, gx, gy, gw, gh) def _draw_context_menu(self, renderer): """Draw the right-click context menu.""" self._renderer.draw_context_menu(renderer) # ==================================================================== # Input handling (delegated to _TimelineInput) # ==================================================================== def _on_gui_input(self, event): """Handle mouse, keyboard, and scroll input.""" self._input.on_gui_input(event) def _handle_keyboard(self, event): """Handle keyboard shortcuts.""" self._input.handle_keyboard(event) def _handle_scroll(self, event): """Handle scroll wheel for zoom (with Ctrl) or vertical scroll.""" self._input.handle_scroll(event) def _handle_header_click(self, px, py): """Handle clicks in the header bar.""" self._input.handle_header_click(px, py) def _handle_track_click(self, event, px, py): """Handle mouse clicks in the track area.""" self._input.handle_track_click(event, px, py) def _handle_track_right_click(self, px, py): """Show context menu on right-click in track area.""" self._input.handle_track_right_click(px, py) def _handle_context_menu_click(self, px, py): """Handle click on a context menu item.""" self._input.handle_context_menu_click(px, py) def _is_point_in_context_menu(self, point) -> bool: """Check if a point is inside the context menu.""" return self._input.is_point_in_context_menu(point) # ==================================================================== # Hit testing # ==================================================================== def _hit_test_keyframe(self, track: Track, track_name: str, px: float, py: float, kf_area_x: float) -> int | None: """Return the index of the keyframe closest to (px, py), or None.""" return self._input.hit_test_keyframe(track, track_name, px, py, kf_area_x) # ==================================================================== # Transport controls # ==================================================================== def _handle_transport(self, action: str): """Execute a transport action on the AnimationPlayer.""" if not self._player: return if action == "play": clip_name = self._get_active_clip_name() if clip_name: if not (self._player.playing and self._player.current_clip == clip_name): self._player.play(clip_name, loop=self._player.loop) elif action == "pause": self._player.pause() elif action == "stop": self._player.stop() elif action == "rewind": self._player.seek(0.0) def _toggle_play_pause(self): """Toggle between play and pause.""" if not self._player: return if self._player.playing: self._player.pause() else: clip_name = self._get_active_clip_name() if clip_name: if self._player.current_clip == clip_name: self._player.resume() else: self._player.play(clip_name, loop=self._player.loop) def _toggle_loop(self): """Toggle loop mode on the player.""" if self._player: self._player.loop = not self._player.loop def _cycle_speed(self): """Cycle through speed presets.""" if not self._player: return self._speed_index = (self._speed_index + 1) % len(_SPEED_OPTIONS) self._player.speed_scale = _SPEED_OPTIONS[self._speed_index] def _cycle_clip(self): """Cycle to the next clip in the player.""" names = self._get_clip_names() if not names: return self._clip_index = (self._clip_index + 1) % len(names) self._selected_track = None self._selected_keyframe = None def _seek_to_x(self, rel_x: float): """Seek the player to a time corresponding to a pixel offset.""" if not self._player: return time = max(0.0, self._x_to_time(rel_x)) clip = self._get_active_clip() if clip: time = min(time, clip.duration) self._player.seek(time) # ==================================================================== # Keyframe operations (all via UndoStack) # ==================================================================== def _insert_keyframe_at_current_time(self): """Insert a keyframe at the player's current time for the selected track. Reads the current property value from the player's target node. """ if not self._player or not self._selected_track: return clip = self._get_active_clip() if not clip or self._selected_track not in clip.tracks: return time = self._player.current_time track = clip.tracks[self._selected_track] prop_name = track.property_name # Read current value from target value = None if self._player.target and hasattr(self._player.target, prop_name): value = getattr(self._player.target, prop_name) else: # Evaluate current value from the track itself value = track.evaluate(time) if value is None: return self._insert_keyframe(self._selected_track, time, value) def _insert_keyframe(self, track_name: str, time: float, value: Any = None): """Insert a keyframe into a track with undo support.""" clip = self._get_active_clip() if not clip or track_name not in clip.tracks: return track = clip.tracks[track_name] if value is None: # Evaluate the track at the given time to get a default value value = track.evaluate(time) if value is None and track.keyframes: value = track.keyframes[-1][1] elif value is None: value = 0.0 # Capture state for undo old_keyframes = list(track.keyframes) def do_insert(): track.add_keyframe(time, value) def undo_insert(): track.keyframes[:] = old_keyframes cmd = CallableCommand( do_insert, undo_insert, description=f"Insert keyframe on {track_name} at {time:.2f}s", ) self.state.undo_stack.push(cmd) self.timeline_changed.emit() def _delete_selected_keyframe(self): """Delete the currently selected keyframe.""" if not self._selected_keyframe: return track_name, kf_idx = self._selected_keyframe self._delete_keyframe(track_name, kf_idx) def _delete_keyframe(self, track_name: str, kf_idx: int): """Delete a keyframe from a track with undo support.""" clip = self._get_active_clip() if not clip or track_name not in clip.tracks: return track = clip.tracks[track_name] if kf_idx < 0 or kf_idx >= len(track.keyframes): return old_keyframes = list(track.keyframes) removed_kf = track.keyframes[kf_idx] def do_delete(): if kf_idx < len(track.keyframes): track.keyframes.pop(kf_idx) def undo_delete(): track.keyframes[:] = old_keyframes cmd = CallableCommand( do_delete, undo_delete, description=f"Delete keyframe on {track_name} at {removed_kf[0]:.2f}s", ) self.state.undo_stack.push(cmd) self._selected_keyframe = None self.timeline_changed.emit() def _finalize_keyframe_drag(self, px: float, kf_area_x: float): """Finalize a keyframe drag, moving it to a new time with undo.""" if not self._selected_keyframe: return track_name, kf_idx = self._selected_keyframe clip = self._get_active_clip() if not clip or track_name not in clip.tracks: return track = clip.tracks[track_name] if kf_idx < 0 or kf_idx >= len(track.keyframes): return new_time = max(0.0, self._x_to_time(px - kf_area_x)) if clip.duration > 0: new_time = min(new_time, clip.duration) old_time = self._drag_kf_original_time if abs(new_time - old_time) < 0.001: return # No meaningful move old_keyframes = list(track.keyframes) kf_value = track.keyframes[kf_idx][1] def do_move(): track.keyframes.pop(kf_idx) track.add_keyframe(new_time, kf_value) def undo_move(): track.keyframes[:] = old_keyframes cmd = CallableCommand( do_move, undo_move, description=f"Move keyframe on {track_name}: {old_time:.2f}s -> {new_time:.2f}s", ) self.state.undo_stack.push(cmd) # Update selection to the new index for i, (t, _) in enumerate(track.keyframes): if abs(t - new_time) < 0.001: self._selected_keyframe = (track_name, i) break self.timeline_changed.emit() def _remove_track(self, track_name: str): """Remove a track from the active clip with undo support.""" clip = self._get_active_clip() if not clip or track_name not in clip.tracks: return removed_track = clip.tracks[track_name] def do_remove(): if track_name in clip.tracks: del clip.tracks[track_name] def undo_remove(): clip.tracks[track_name] = removed_track cmd = CallableCommand( do_remove, undo_remove, description=f"Remove track '{track_name}'", ) self.state.undo_stack.push(cmd) if self._selected_track == track_name: self._selected_track = None self._selected_keyframe = None self.timeline_changed.emit() def _add_track_dialog(self): """Add a new empty track to the active clip. Names the track after the next un-tracked property on the player's target, or a generic name when none is available. """ clip = self._get_active_clip() if not clip: return # Try to find a property on the target that doesn't have a track yet track_name = self._suggest_track_name(clip) old_tracks = dict(clip.tracks) def do_add(): if track_name not in clip.tracks: new_track = Track(track_name) clip.tracks[track_name] = new_track def undo_add(): clip.tracks.clear() clip.tracks.update(old_tracks) cmd = CallableCommand( do_add, undo_add, description=f"Add track '{track_name}'", ) self.state.undo_stack.push(cmd) self._selected_track = track_name self.timeline_changed.emit() def _suggest_track_name(self, clip: AnimationClip) -> str: """Suggest a property name for a new track.""" common_props = [ "position", "rotation", "scale", "position.x", "position.y", "position.z", "rotation.x", "rotation.y", "rotation.z", "scale.x", "scale.y", "scale.z", "colour", "opacity", "visible", ] # Check target node for properties if self._player and self._player.target: target = self._player.target for prop in common_props: base_prop = prop.split(".")[0] if hasattr(target, base_prop) and prop not in clip.tracks: return prop # Fallback: generic numbered name i = len(clip.tracks) while f"track_{i}" in clip.tracks: i += 1 return f"track_{i}" def _set_track_easing(self, track_name: str, easing_name: str): """Set the easing function on a track with undo support.""" from simvx.core.animation.tween import ( ease_in_out_quad, ease_in_quad, ease_linear, ease_out_quad, ) clip = self._get_active_clip() if not clip or track_name not in clip.tracks: return track = clip.tracks[track_name] old_easing = track.easing easing_map = { "linear": ease_linear, "ease_in": ease_in_quad, "ease_out": ease_out_quad, "ease_inout": ease_in_out_quad, } new_easing = easing_map.get(easing_name, ease_linear) def do_set(): track.easing = new_easing def undo_set(): track.easing = old_easing cmd = CallableCommand( do_set, undo_set, description=f"Set {track_name} easing to {easing_name}", ) self.state.undo_stack.push(cmd) self.timeline_changed.emit()