"""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()