"""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 | <> <> <> <> |
+----------------------------------------------------+
"""
from __future__ import annotations
import math
from typing import Any
from simvx.core import (
AnimationClip,
AnimationPlayer,
CallableCommand,
Control,
Signal,
Track,
Vec2,
)
# ============================================================================
# Colour palette
# ============================================================================
_BG = (0.13, 0.13, 0.13, 1.0)
_HEADER_BG = (0.10, 0.10, 0.10, 1.0)
_RULER_BG = (0.16, 0.16, 0.16, 1.0)
_TRACK_BG = (0.15, 0.15, 0.15, 1.0)
_TRACK_ALT_BG = (0.17, 0.17, 0.17, 1.0)
_LABEL_BG = (0.12, 0.12, 0.12, 1.0)
_SEPARATOR = (0.25, 0.25, 0.25, 1.0)
_TEXT_COLOUR = (0.85, 0.85, 0.85, 1.0)
_TEXT_DIM = (0.55, 0.55, 0.55, 1.0)
_TEXT_ACCENT = (0.3, 0.7, 1.0, 1.0)
_PLAYHEAD_COLOUR = (1.0, 0.2, 0.2, 1.0)
_KEYFRAME_COLOUR = (1.0, 1.0, 1.0, 1.0)
_KEYFRAME_SELECTED = (1.0, 0.85, 0.0, 1.0)
_TICK_MAJOR = (0.45, 0.45, 0.45, 1.0)
_TICK_MINOR = (0.28, 0.28, 0.28, 1.0)
_BTN_NORMAL = (0.22, 0.22, 0.22, 1.0)
_BTN_HOVER = (0.30, 0.30, 0.30, 1.0)
_BTN_ACTIVE = (0.18, 0.55, 0.90, 1.0)
_REMOVE_COLOUR = (0.8, 0.3, 0.3, 1.0)
# ============================================================================
# Layout constants
# ============================================================================
_HEADER_HEIGHT = 32.0
_RULER_HEIGHT = 24.0
_TRACK_HEIGHT = 28.0
_LABEL_WIDTH = 120.0
_PADDING = 4.0
_FONT_SCALE = 11.0 / 14.0
_SMALL_FONT = 10.0 / 14.0
_DIAMOND_HALF = 5 # half-size of keyframe diamond
_REMOVE_BTN_SIZE = 14.0
_ZOOM_MIN = 20.0
_ZOOM_MAX = 800.0
_ZOOM_DEFAULT = 100.0
_SCROLL_STEP = 20.0
_SPEED_OPTIONS = [0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0]
# ============================================================================
# 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.
Args:
editor_state: The central EditorState 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
# Signal emitted when the panel wants the editor to refresh
self.timeline_changed = Signal()
# ====================================================================
# Lifecycle
# ====================================================================
[docs]
def ready(self):
"""Connect to editor state signals."""
if hasattr(self.state, 'selection_changed'):
self.state.selection_changed.connect(self._on_selection_changed)
[docs]
def 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
# ====================================================================
[docs]
def draw(self, renderer):
gx, gy, gw, gh = self.get_global_rect()
# Background
renderer.draw_filled_rect(gx, gy, gw, gh, _BG)
if not self._player:
self._draw_empty_message(renderer, gx, gy, gw, gh)
return
clip = self._get_active_clip()
renderer.push_clip(gx, gy, gw, gh)
self._draw_header(renderer, gx, gy, gw)
self._draw_ruler(renderer, clip, gx, gy, gw)
self._draw_tracks(renderer, clip, gx, gy, gw, gh)
self._draw_playhead(renderer, clip, gx, gy, gw, gh)
if self._context_menu_visible:
self._draw_context_menu(renderer)
renderer.pop_clip()
def _draw_empty_message(self, renderer, x, y, w, h):
"""Draw placeholder when no AnimationPlayer is selected."""
msg = "Select an AnimationPlayer node"
tw = renderer.text_width(msg, _FONT_SCALE)
tx = x + (w - tw) / 2
ty = y + (h - 14) / 2
renderer.draw_text_coloured(msg, tx, ty, _FONT_SCALE, _TEXT_DIM)
def _draw_header(self, renderer, gx, gy, gw):
"""Draw the top header bar with playback controls."""
hx, hy, hw, hh = gx, gy, gw, _HEADER_HEIGHT
# Header background
renderer.draw_filled_rect(hx, hy, hw, hh, _HEADER_BG)
# Bottom separator
renderer.draw_filled_rect(hx, hy + hh - 1, hw, 1, _SEPARATOR)
cx = hx + _PADDING
cy = hy + (hh - 12) / 2
# Clip selector
clip_name = self._get_active_clip_name() or "(no clip)"
clip_label = f"[{clip_name}]"
renderer.draw_text_coloured(clip_label, cx, cy, _FONT_SCALE, _TEXT_ACCENT)
cx += renderer.text_width(clip_label, _FONT_SCALE) + 8
# Player name
if self._player_node and hasattr(self._player_node, 'name'):
name_str = str(self._player_node.name)
renderer.draw_text_coloured(name_str, cx, cy, _SMALL_FONT, _TEXT_DIM)
cx += renderer.text_width(name_str, _SMALL_FONT) + 12
# Separator
renderer.draw_filled_rect(cx, hy + 4, 1, hh - 8, _SEPARATOR)
cx += 6
# Transport buttons
transport = [
("\u25C0\u25C0", "rewind"),
("\u25B6", "play"),
("\u23F8", "pause"),
("\u23F9", "stop"),
]
for label, action in transport:
btn_w = renderer.text_width(label, _FONT_SCALE) + 8
is_active = (action == "play" and self._player and self._player.playing)
bg = _BTN_ACTIVE if is_active else _BTN_NORMAL
renderer.draw_filled_rect(cx, hy + 4, btn_w, hh - 8, bg)
renderer.draw_text_coloured(label, cx + 4, cy, _FONT_SCALE, _TEXT_COLOUR)
cx += btn_w + 2
# Loop toggle
cx += 4
loop_on = self._player.loop if self._player else False
loop_label = "Loop"
loop_w = renderer.text_width(loop_label, _SMALL_FONT) + 8
loop_bg = _BTN_ACTIVE if loop_on else _BTN_NORMAL
renderer.draw_filled_rect(cx, hy + 4, loop_w, hh - 8, loop_bg)
renderer.draw_text_coloured(loop_label, cx + 4, cy, _SMALL_FONT, _TEXT_COLOUR)
cx += loop_w + 6
# Speed label
speed = self._player.speed_scale if self._player else 1.0
speed_str = f"{speed:.2f}x"
renderer.draw_text_coloured(speed_str, cx, cy, _SMALL_FONT, _TEXT_DIM)
cx += renderer.text_width(speed_str, _SMALL_FONT) + 8
# Add Track button (right-aligned)
add_label = "+ Track"
add_w = renderer.text_width(add_label, _SMALL_FONT) + 8
add_x = gx + gw - add_w - _PADDING
renderer.draw_filled_rect(add_x, hy + 4, add_w, hh - 8, _BTN_NORMAL)
renderer.draw_text_coloured(add_label, add_x + 4, cy, _SMALL_FONT, _TEXT_COLOUR)
# Time display (right of speed, left of add-track)
if self._player:
clip = self._get_active_clip()
dur = clip.duration if clip else 0.0
time_str = f"{self._player.current_time:.2f} / {dur:.2f}s"
tw = renderer.text_width(time_str, _SMALL_FONT)
time_x = add_x - tw - 12
renderer.draw_text_coloured(time_str, time_x, cy, _SMALL_FONT, _TEXT_DIM)
def _draw_ruler(self, renderer, clip, gx, gy, gw):
"""Draw the timeline ruler with tick marks."""
rx, ry, rw, rh = self._ruler_rect()
# Background
renderer.draw_filled_rect(rx, ry, rw, rh, _RULER_BG)
# Label area background
renderer.draw_filled_rect(gx, ry, _LABEL_WIDTH, rh, _HEADER_BG)
# Bottom separator
renderer.draw_filled_rect(gx, ry + rh - 1, gw, 1, _SEPARATOR)
# Time label in label column
renderer.draw_text_coloured("Time", gx + _PADDING, ry + 6, _SMALL_FONT, _TEXT_DIM)
# Determine visible time range
t_start = max(0.0, self._x_to_time(0))
t_end = self._x_to_time(rw)
# Major ticks at 1s intervals
first_major = max(0, int(math.floor(t_start)))
last_major = int(math.ceil(t_end)) + 1
renderer.push_clip(rx, ry, rw, rh)
for sec in range(first_major, last_major):
# Major tick
tx = rx + self._time_to_x(float(sec))
if rx <= tx <= rx + rw:
renderer.draw_line_coloured(tx, ry + rh - 12, tx, ry + rh - 1, _TICK_MAJOR)
label = f"{sec}"
renderer.draw_text_coloured(label, tx + 2, ry + 2, _SMALL_FONT, _TEXT_DIM)
# Minor ticks at 0.25s intervals
for q in range(1, 4):
minor_t = sec + q * 0.25
mx = rx + self._time_to_x(minor_t)
if rx <= mx <= rx + rw:
tick_h = 4 if q == 2 else 3
renderer.draw_line_coloured(mx, ry + rh - tick_h - 1, mx, ry + rh - 1, _TICK_MINOR)
renderer.pop_clip()
def _draw_tracks(self, renderer, clip, gx, gy, gw, gh):
"""Draw the track rows with keyframe diamonds."""
tx, ty, tw, th = self._tracks_rect()
if not clip:
return
renderer.push_clip(tx, ty, tw, th)
track_names = sorted(clip.tracks.keys())
kf_area_x = gx + _LABEL_WIDTH
kf_area_w = gw - _LABEL_WIDTH
for i, track_name in enumerate(track_names):
row_y = ty + i * _TRACK_HEIGHT - self._scroll_y
# Skip rows outside visible area
if row_y + _TRACK_HEIGHT < ty or row_y > ty + th:
continue
track = clip.tracks[track_name]
is_selected = (self._selected_track == track_name)
row_bg = _TRACK_ALT_BG if (i % 2 == 1) else _TRACK_BG
if is_selected:
row_bg = (0.20, 0.25, 0.35, 1.0)
# Label area background
renderer.draw_filled_rect(gx, row_y, _LABEL_WIDTH, _TRACK_HEIGHT, _LABEL_BG)
# Track label
label_y = row_y + (_TRACK_HEIGHT - 11) / 2
renderer.draw_text_coloured(
track_name, gx + _PADDING, label_y,
_SMALL_FONT, _TEXT_COLOUR if is_selected else _TEXT_DIM)
# Remove button "x"
rm_x = gx + _LABEL_WIDTH - _REMOVE_BTN_SIZE - 2
rm_y = row_y + (_TRACK_HEIGHT - _REMOVE_BTN_SIZE) / 2
renderer.draw_text_coloured(
"x", rm_x + 2, rm_y + 1, _SMALL_FONT, _REMOVE_COLOUR)
# Label/keyframe separator
renderer.draw_filled_rect(gx + _LABEL_WIDTH - 1, row_y, 1, _TRACK_HEIGHT, _SEPARATOR)
# Keyframe area background
renderer.draw_filled_rect(kf_area_x, row_y, kf_area_w, _TRACK_HEIGHT, row_bg)
# Draw keyframe diamonds
renderer.push_clip(kf_area_x, row_y, kf_area_w, _TRACK_HEIGHT)
for kf_idx, (kf_time, _kf_value) in enumerate(track.keyframes):
dx = kf_area_x + self._time_to_x(kf_time)
dy = row_y + _TRACK_HEIGHT / 2
is_kf_selected = (
self._selected_keyframe is not None
and self._selected_keyframe[0] == track_name
and self._selected_keyframe[1] == kf_idx
)
colour = _KEYFRAME_SELECTED if is_kf_selected else _KEYFRAME_COLOUR
# Draw diamond as a rotated square (4 lines)
hs = _DIAMOND_HALF
renderer.draw_line_coloured(dx, dy - hs, dx + hs, dy, colour)
renderer.draw_line_coloured(dx + hs, dy, dx, dy + hs, colour)
renderer.draw_line_coloured(dx, dy + hs, dx - hs, dy, colour)
renderer.draw_line_coloured(dx - hs, dy, dx, dy - hs, colour)
# Fill diamond interior with smaller rect
fill_hs = hs - 1
if fill_hs > 0:
renderer.draw_filled_rect(
dx - fill_hs, dy - fill_hs,
fill_hs * 2, fill_hs * 2, colour)
renderer.pop_clip()
# Row separator
renderer.draw_filled_rect(gx, row_y + _TRACK_HEIGHT - 1, gw, 1, _SEPARATOR)
renderer.pop_clip()
def _draw_playhead(self, renderer, clip, gx, gy, gw, gh):
"""Draw the vertical playhead line at current_time."""
if not self._player:
return
kf_area_x = gx + _LABEL_WIDTH
kf_area_w = gw - _LABEL_WIDTH
head_x = kf_area_x + self._time_to_x(self._player.current_time)
# Only draw if visible
if head_x < kf_area_x or head_x > kf_area_x + kf_area_w:
return
top_y = gy + _HEADER_HEIGHT
bot_y = gy + gh
renderer.draw_line_coloured(head_x, top_y, head_x, bot_y, _PLAYHEAD_COLOUR)
# Playhead handle (small triangle at top of ruler)
handle_y = gy + _HEADER_HEIGHT
handle_size = 6
renderer.draw_filled_rect(
head_x - handle_size / 2, handle_y,
handle_size, handle_size, _PLAYHEAD_COLOUR)
def _draw_context_menu(self, renderer):
"""Draw the right-click context menu."""
if not self._context_menu_items:
return
mx, my = self._context_menu_pos
item_h = 22.0
menu_w = 140.0
menu_h = len(self._context_menu_items) * item_h + 4
# Background
renderer.draw_filled_rect(mx, my, menu_w, menu_h, _HEADER_BG)
renderer.draw_rect_coloured(mx, my, menu_w, menu_h, _SEPARATOR)
for i, (label, _action) in enumerate(self._context_menu_items):
iy = my + 2 + i * item_h
renderer.draw_text_coloured(
label, mx + 8, iy + 4, _SMALL_FONT, _TEXT_COLOUR)
# ====================================================================
# Input handling
# ====================================================================
def _on_gui_input(self, event):
"""Handle mouse, keyboard, and scroll input."""
if not self.is_point_inside(event.position):
if self._context_menu_visible:
self._context_menu_visible = False
return
# Close context menu on any click outside it
if event.button and event.pressed and self._context_menu_visible:
if not self._is_point_in_context_menu(event.position):
self._context_menu_visible = False
# Scroll wheel (zoom)
if event.key in ("scroll_up", "scroll_down"):
self._handle_scroll(event)
return
# Keyboard shortcuts (non-scroll keys)
if event.key:
self._handle_keyboard(event)
return
px = event.position.x if hasattr(event.position, 'x') else event.position[0]
py = event.position.y if hasattr(event.position, 'y') else event.position[1]
# Context menu click
if self._context_menu_visible and event.button == 1 and event.pressed:
self._handle_context_menu_click(px, py)
return
# Header clicks
hx, hy, hw, hh = self._header_rect()
if hy <= py <= hy + hh and event.button == 1 and event.pressed:
self._handle_header_click(px, py)
return
# Ruler clicks (seek)
rx, ry, rw, rh = self._ruler_rect()
if rx <= px <= rx + rw and ry <= py <= ry + rh:
if event.button == 1:
if event.pressed:
self._dragging_playhead = True
self._seek_to_x(px - rx)
else:
self._dragging_playhead = False
return
# Track area
tx, ty, tw, th = self._tracks_rect()
if ty <= py <= ty + th:
if event.button == 1:
self._handle_track_click(event, px, py)
elif event.button == 3 and event.pressed:
self._handle_track_right_click(px, py)
return
# Playhead drag continuation
if self._dragging_playhead and event.button == 1:
if not event.pressed:
self._dragging_playhead = False
else:
self._seek_to_x(px - (self._keyframe_area_left() - self.get_global_rect()[0]))
def _handle_keyboard(self, event):
"""Handle keyboard shortcuts."""
if not event.pressed:
return
key = event.key
# "K" — insert keyframe at current time for selected track
if key in ("k", "K"):
self._insert_keyframe_at_current_time()
return
# Delete — remove selected keyframe
if key in ("delete", "Delete", "backspace", "Backspace"):
self._delete_selected_keyframe()
return
# Space — toggle play/pause
if key == "space":
self._toggle_play_pause()
return
def _handle_scroll(self, event):
"""Handle scroll wheel for zoom (with Ctrl) or vertical scroll."""
key = event.key
if key == "scroll_up":
# Zoom in
self._zoom = min(_ZOOM_MAX, self._zoom * 1.15)
elif key == "scroll_down":
# Zoom out
self._zoom = max(_ZOOM_MIN, self._zoom / 1.15)
def _handle_header_click(self, px, py):
"""Handle clicks in the header bar."""
gx, gy, gw, _ = self.get_global_rect()
hx = gx
hy = gy
hh = _HEADER_HEIGHT
hy + (hh - 12) / 2
cx = hx + _PADDING
if not self._player:
return
# Approximate hit-testing for header buttons by walking the layout
# Clip selector region
clip_name = self._get_active_clip_name() or "(no clip)"
clip_label = f"[{clip_name}]"
clip_w = len(clip_label) * 7 # approximate text width
if cx <= px <= cx + clip_w:
self._cycle_clip()
return
cx += clip_w + 8
# Player name (skip)
if self._player_node and hasattr(self._player_node, 'name'):
cx += len(str(self._player_node.name)) * 6 + 12
cx += 6 # separator
# Transport buttons (approximate widths)
transport = [
("rewind", 24),
("play", 20),
("pause", 20),
("stop", 20),
]
for action, btn_w in transport:
if cx <= px <= cx + btn_w:
self._handle_transport(action)
return
cx += btn_w + 2
cx += 4
# Loop toggle
loop_w = 35
if cx <= px <= cx + loop_w:
self._toggle_loop()
return
cx += loop_w + 6
# Speed
speed_w = 40
if cx <= px <= cx + speed_w:
self._cycle_speed()
return
cx += speed_w + 8
# Add Track button (right-aligned, approximate)
add_w = 55
add_x = gx + gw - add_w - _PADDING
if add_x <= px <= add_x + add_w:
self._add_track_dialog()
return
def _handle_track_click(self, event, px, py):
"""Handle mouse clicks in the track area."""
gx, _, gw, _ = self.get_global_rect()
tx, ty, tw, th = self._tracks_rect()
clip = self._get_active_clip()
if not clip:
return
track_names = sorted(clip.tracks.keys())
kf_area_x = gx + _LABEL_WIDTH
# Determine which track row was clicked
rel_y = py - ty + self._scroll_y
row_index = int(rel_y / _TRACK_HEIGHT)
if row_index < 0 or row_index >= len(track_names):
self._selected_track = None
self._selected_keyframe = None
return
track_name = track_names[row_index]
# Check if click is in the label area (remove button)
if px < kf_area_x:
rm_x = gx + _LABEL_WIDTH - _REMOVE_BTN_SIZE - 2
if px >= rm_x:
if event.pressed:
self._remove_track(track_name)
return
# Select track
if event.pressed:
self._selected_track = track_name
self._selected_keyframe = None
return
# Keyframe area — check for keyframe hit
if event.pressed:
self._selected_track = track_name
track = clip.tracks[track_name]
# Find closest keyframe to click position
hit_kf = self._hit_test_keyframe(track, track_name, px, py, kf_area_x)
if hit_kf is not None:
self._selected_keyframe = (track_name, hit_kf)
self._dragging_keyframe = True
self._drag_kf_original_time = track.keyframes[hit_kf][0]
else:
self._selected_keyframe = None
self._dragging_keyframe = False
else:
# Mouse release — finalize keyframe drag
if self._dragging_keyframe and self._selected_keyframe:
self._finalize_keyframe_drag(px, kf_area_x)
self._dragging_keyframe = False
def _handle_track_right_click(self, px, py):
"""Show context menu on right-click in track area."""
clip = self._get_active_clip()
if not clip:
return
gx, _, _, _ = self.get_global_rect()
tx, ty, tw, th = self._tracks_rect()
kf_area_x = gx + _LABEL_WIDTH
track_names = sorted(clip.tracks.keys())
rel_y = py - ty + self._scroll_y
row_index = int(rel_y / _TRACK_HEIGHT)
if row_index < 0 or row_index >= len(track_names):
return
track_name = track_names[row_index]
track = clip.tracks[track_name]
# Check if right-click hit a keyframe
hit_kf = self._hit_test_keyframe(track, track_name, px, py, kf_area_x)
items = []
if hit_kf is not None:
self._selected_keyframe = (track_name, hit_kf)
items.append(("Delete Keyframe", ("delete_kf", track_name, hit_kf)))
items.append(("Set Easing: Linear", ("easing", track_name, "linear")))
items.append(("Set Easing: Ease In", ("easing", track_name, "ease_in")))
items.append(("Set Easing: Ease Out", ("easing", track_name, "ease_out")))
items.append(("Set Easing: Ease InOut", ("easing", track_name, "ease_inout")))
else:
# Right-click on empty area — offer to insert
time_at_click = self._x_to_time(px - kf_area_x)
items.append(("Insert Keyframe", ("insert_kf", track_name, time_at_click)))
self._context_menu_items = items
self._context_menu_pos = (px, py)
self._context_menu_visible = True
def _handle_context_menu_click(self, px, py):
"""Handle click on a context menu item."""
mx, my = self._context_menu_pos
item_h = 22.0
menu_w = 140.0
if not (mx <= px <= mx + menu_w):
self._context_menu_visible = False
return
rel_y = py - my - 2
idx = int(rel_y / item_h)
if idx < 0 or idx >= len(self._context_menu_items):
self._context_menu_visible = False
return
_label, action = self._context_menu_items[idx]
self._context_menu_visible = False
if not isinstance(action, tuple):
return
cmd_type = action[0]
if cmd_type == "delete_kf":
_, track_name, kf_idx = action
self._delete_keyframe(track_name, kf_idx)
elif cmd_type == "insert_kf":
_, track_name, time_val = action
self._insert_keyframe(track_name, time_val)
elif cmd_type == "easing":
_, track_name, easing_name = action
self._set_track_easing(track_name, easing_name)
def _is_point_in_context_menu(self, point) -> bool:
"""Check if a point is inside the context menu."""
if not self._context_menu_visible:
return False
mx, my = self._context_menu_pos
item_h = 22.0
menu_w = 140.0
menu_h = len(self._context_menu_items) * item_h + 4
ppx = point.x if hasattr(point, 'x') else point[0]
ppy = point.y if hasattr(point, 'y') else point[1]
return (mx <= ppx <= mx + menu_w and my <= ppy <= my + menu_h)
# ====================================================================
# 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."""
hit_radius = _DIAMOND_HALF + 3
best_idx = None
best_dist = float('inf')
tx, ty, _, _ = self._tracks_rect()
track_names = sorted(self._get_active_clip().tracks.keys())
try:
row_index = track_names.index(track_name)
except ValueError:
return None
row_y = ty + row_index * _TRACK_HEIGHT - self._scroll_y
diamond_cy = row_y + _TRACK_HEIGHT / 2
for i, (kf_time, _) in enumerate(track.keyframes):
diamond_cx = kf_area_x + self._time_to_x(kf_time)
dist = math.sqrt((px - diamond_cx) ** 2 + (py - diamond_cy) ** 2)
if dist < hit_radius and dist < best_dist:
best_dist = dist
best_idx = i
return best_idx
# ====================================================================
# 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()
self._player.seek(0.0)
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.
In a full editor this would open a property picker dialog.
For now, it creates a track named after the next available
property from the player's target, or a generic name.
"""
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()