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 | <>  <>      <>           <>               |
    +----------------------------------------------------+
"""

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