Source code for simvx.editor.panels.animation_editor

"""Animation Editor Panel -- Extended timeline editor with blend tree support.

Supplements the existing AnimationPanel with higher-level editing features:
- Animation list management (create/rename/delete clips)
- Track management (add/remove property tracks)
- Keyframe value editing with easing presets
- Loop/speed controls per clip
- Copy/paste keyframe ranges
- Onion skinning toggle

Integrates with the core AnimationPlayer, AnimationClip, Track, and
AnimationTree systems.

Layout:
    +----------------------------------------------------------+
    | [clip v][+][-]  | Speed [==|--] 1.0x  Loop [x]  Snap [x] |
    |----------------------------------------------------------+
    | Clip Properties                                           |
    |   Duration: [  1.0  ]   Easing: [linear   v]             |
    |----------------------------------------------------------+
    | Track Management                                          |
    |   [+ Add Track]  [Copy KF]  [Paste KF]  [Onion]          |
    |----------------------------------------------------------+
    | v Tracks (property list with keyframe info)               |
    |   position.x   3 keys   [insert] [delete]                |
    |   position.y   2 keys   [insert] [delete]                |
    |   rotation     4 keys   [insert] [delete]                |
    +----------------------------------------------------------+
"""

from __future__ import annotations

import logging
from typing import Any

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

log = logging.getLogger(__name__)

__all__ = ["AnimationEditorPanel"]

# Layout
_ROW_HEIGHT = 26.0
_HEADER_HEIGHT = 32.0
_SECTION_HEIGHT = 28.0
_PADDING = 6.0
_LABEL_WIDTH = 100.0
_FONT_SCALE = 11.0 / 14.0

# Easing presets available in the dropdown
_EASING_PRESETS = [
    "linear",
    "in_quad",
    "out_quad",
    "in_out_quad",
    "in_cubic",
    "out_cubic",
    "in_out_cubic",
    "in_sine",
    "out_sine",
    "in_out_sine",
    "in_expo",
    "out_expo",
    "in_out_expo",
    "in_back",
    "out_back",
    "in_out_back",
    "in_elastic",
    "out_elastic",
    "in_out_elastic",
    "in_bounce",
    "out_bounce",
    "in_out_bounce",
]

# Speed multipliers
_SPEED_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]


def _resolve_easing(name: str):
    """Resolve easing name to function from simvx.core.animation.tween."""
    from simvx.core.animation import tween as _tween_mod

    fn = getattr(_tween_mod, f"ease_{name}", None)
    return fn or _tween_mod.ease_linear


# ============================================================================
# _TrackRow -- One row per animated property
# ============================================================================


class _TrackRow:
    """Data for a single track row in the editor."""

    __slots__ = ("property_name", "keyframe_count", "selected")

    def __init__(self, property_name: str, keyframe_count: int = 0):
        self.property_name = property_name
        self.keyframe_count = keyframe_count
        self.selected = False


# ============================================================================
# AnimationEditorPanel
# ============================================================================


[docs] class AnimationEditorPanel(Control): """Advanced animation editor panel with clip management and track editing. Provides a property-based view of animation clips: create/rename/delete clips, add/remove tracks, insert keyframes at the current playhead time, adjust easing per-track, and manage loop/speed settings. Args: editor_state: The central EditorState instance. """ def __init__(self, editor_state=None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = get_theme().bg_dark self.size = Vec2(400, 500) # Animation player reference self._player: AnimationPlayer | None = None self._player_node = None # Clip selection self._clip_index: int = 0 # Track rows self._track_rows: list[_TrackRow] = [] self._selected_track_index: int = -1 # Clip properties self._loop: bool = False self._speed: float = 1.0 self._snap_enabled: bool = True self._snap_interval: float = 0.1 self._onion_skinning: bool = False # Keyframe clipboard self._clipboard_keyframes: list[tuple[float, Any]] = [] # Easing selection per track self._track_easings: dict[str, str] = {} # Signals self.clip_changed = Signal() self.track_changed = Signal() # Scroll offset for track list self._scroll_y: float = 0.0 # ==================================================================== # Lifecycle # ====================================================================
[docs] def ready(self): if self.state and hasattr(self.state, "selection_changed"): self.state.selection_changed.connect(self._on_selection_changed)
# ==================================================================== # Player binding # ====================================================================
[docs] def set_player(self, player: AnimationPlayer | None, node=None): """Bind to an AnimationPlayer.""" self._player = player self._player_node = node self._clip_index = 0 self._selected_track_index = -1 self._refresh_tracks()
def _on_selection_changed(self): node = self.state.selection.primary if self.state and hasattr(self.state.selection, "primary") else None if node is None: self.set_player(None) return player = self._find_player(node) self.set_player(player, node) @staticmethod def _find_player(node) -> AnimationPlayer | None: if isinstance(node, AnimationPlayer): return node for attr in dir(node): if attr.startswith("_"): continue try: val = getattr(node, attr) if isinstance(val, AnimationPlayer): return val except Exception: continue if hasattr(node, "children"): for child in node.children: result = AnimationEditorPanel._find_player(child) if result: return result return None # ==================================================================== # Clip management # ==================================================================== def _get_clip_names(self) -> list[str]: if not self._player: return [] return sorted(self._player.clips.keys()) def _get_active_clip(self) -> AnimationClip | None: 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)) return self._player.clips.get(names[idx]) def _get_active_clip_name(self) -> str | None: names = self._get_clip_names() if not names: return None return names[max(0, min(self._clip_index, len(names) - 1))]
[docs] def create_clip(self, name: str, duration: float = 1.0) -> AnimationClip | None: """Create a new animation clip on the active player.""" if not self._player: return None if name in self._player.clips: log.warning("Clip %r already exists", name) return None clip = AnimationClip(name, duration) if self.state: cmd = CallableCommand( lambda: self._player.add_clip(clip), lambda: self._player.clips.pop(name, None), f"Create clip '{name}'", ) self.state.undo_stack.push(cmd) else: self._player.add_clip(clip) self._clip_index = self._get_clip_names().index(name) if name in self._player.clips else 0 self._refresh_tracks() self.clip_changed.emit() return clip
[docs] def delete_clip(self, name: str) -> bool: """Delete a clip from the active player.""" if not self._player or name not in self._player.clips: return False clip = self._player.clips[name] if self.state: cmd = CallableCommand( lambda: self._player.clips.pop(name, None), lambda: self._player.add_clip(clip), f"Delete clip '{name}'", ) self.state.undo_stack.push(cmd) else: self._player.clips.pop(name, None) self._clip_index = 0 self._refresh_tracks() self.clip_changed.emit() return True
[docs] def rename_clip(self, old_name: str, new_name: str) -> bool: """Rename an animation clip.""" if not self._player or old_name not in self._player.clips: return False if new_name in self._player.clips: return False clip = self._player.clips[old_name] def _do(): self._player.clips.pop(old_name, None) clip.name = new_name self._player.clips[new_name] = clip def _undo(): self._player.clips.pop(new_name, None) clip.name = old_name self._player.clips[old_name] = clip if self.state: self.state.undo_stack.push(CallableCommand(_do, _undo, f"Rename clip '{old_name}' -> '{new_name}'")) else: _do() self._refresh_tracks() self.clip_changed.emit() return True
[docs] def set_clip_duration(self, duration: float): """Set the duration of the active clip.""" clip = self._get_active_clip() if not clip: return old = clip.duration if self.state: self.state.undo_stack.push( CallableCommand( lambda: setattr(clip, "duration", duration), lambda: setattr(clip, "duration", old), f"Set clip duration to {duration}", ) ) else: clip.duration = duration
# ==================================================================== # Track management # ==================================================================== def _refresh_tracks(self): """Rebuild track rows from the active clip.""" self._track_rows.clear() clip = self._get_active_clip() if not clip: return for prop_name, track in clip.tracks.items(): row = _TrackRow(prop_name, len(track.keyframes)) self._track_rows.append(row) # Sync loop/speed from player if self._player: self._loop = self._player.loop self._speed = self._player.speed_scale
[docs] def add_track(self, property_name: str) -> bool: """Add a property track to the active clip.""" clip = self._get_active_clip() if not clip or property_name in clip.tracks: return False track = Track(property_name) if self.state: self.state.undo_stack.push( CallableCommand( lambda: clip.tracks.__setitem__(property_name, track), lambda: clip.tracks.pop(property_name, None), f"Add track '{property_name}'", ) ) else: clip.tracks[property_name] = track self._refresh_tracks() self.track_changed.emit() return True
[docs] def remove_track(self, property_name: str) -> bool: """Remove a property track from the active clip.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks: return False track = clip.tracks[property_name] if self.state: self.state.undo_stack.push( CallableCommand( lambda: clip.tracks.pop(property_name, None), lambda: clip.tracks.__setitem__(property_name, track), f"Remove track '{property_name}'", ) ) else: clip.tracks.pop(property_name, None) self._refresh_tracks() self.track_changed.emit() return True
# ==================================================================== # Keyframe operations # ====================================================================
[docs] def insert_keyframe(self, property_name: str, time: float, value: Any) -> bool: """Insert a keyframe on a track at the given time.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks: return False if self._snap_enabled: time = round(time / self._snap_interval) * self._snap_interval track = clip.tracks[property_name] def _do(): track.add_keyframe(time, value) def _undo(): track.keyframes[:] = [(t, v) for t, v in track.keyframes if abs(t - time) > 1e-6] if self.state: desc = f"Insert keyframe on '{property_name}' at t={time:.3f}" self.state.undo_stack.push(CallableCommand(_do, _undo, desc)) else: _do() self._refresh_tracks() return True
[docs] def delete_keyframe(self, property_name: str, keyframe_index: int) -> bool: """Delete a keyframe by index from a track.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks: return False track = clip.tracks[property_name] if keyframe_index < 0 or keyframe_index >= len(track.keyframes): return False kf = track.keyframes[keyframe_index] def _do(): if keyframe_index < len(track.keyframes): track.keyframes.pop(keyframe_index) def _undo(): track.keyframes.insert(keyframe_index, kf) if self.state: self.state.undo_stack.push(CallableCommand(_do, _undo, f"Delete keyframe on '{property_name}'")) else: _do() self._refresh_tracks() return True
[docs] def copy_keyframes(self, property_name: str) -> int: """Copy all keyframes from a track to the clipboard. Returns count.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks: return 0 track = clip.tracks[property_name] self._clipboard_keyframes = list(track.keyframes) return len(self._clipboard_keyframes)
[docs] def paste_keyframes(self, property_name: str, time_offset: float = 0.0) -> int: """Paste clipboard keyframes onto a track. Returns count pasted.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks or not self._clipboard_keyframes: return 0 track = clip.tracks[property_name] pasted = [(t + time_offset, v) for t, v in self._clipboard_keyframes] def _do(): for t, v in pasted: track.add_keyframe(t, v) def _undo(): for t, _ in pasted: track.keyframes[:] = [(kt, kv) for kt, kv in track.keyframes if abs(kt - t) > 1e-6] if self.state: desc = f"Paste {len(pasted)} keyframes on '{property_name}'" self.state.undo_stack.push(CallableCommand(_do, _undo, desc)) else: _do() self._refresh_tracks() return len(pasted)
# ==================================================================== # Track easing # ====================================================================
[docs] def set_track_easing(self, property_name: str, easing_name: str): """Set the easing function for a track.""" clip = self._get_active_clip() if not clip or property_name not in clip.tracks: return track = clip.tracks[property_name] old_easing = track.easing new_easing = _resolve_easing(easing_name) def _do(): track.easing = new_easing self._track_easings[property_name] = easing_name def _undo(): track.easing = old_easing self._track_easings.pop(property_name, None) if self.state: self.state.undo_stack.push(CallableCommand(_do, _undo, f"Set easing '{easing_name}' on '{property_name}'")) else: _do()
# ==================================================================== # Playback controls # ====================================================================
[docs] def set_loop(self, loop: bool): """Toggle loop mode.""" self._loop = loop if self._player: self._player.loop = loop
[docs] def set_speed(self, speed: float): """Set playback speed.""" self._speed = max(0.01, speed) if self._player: self._player.speed_scale = self._speed
[docs] def play(self): """Play the active clip.""" if not self._player: return name = self._get_active_clip_name() if name: self._player.play(name, loop=self._loop) self._player.speed_scale = self._speed
[docs] def pause(self): """Pause playback.""" if self._player: self._player.pause()
[docs] def stop(self): """Stop playback and reset to start.""" if self._player: self._player.stop() self._player.current_time = 0.0
[docs] def seek(self, time: float): """Seek to a specific time.""" if self._player: self._player.seek(time)
@property def is_playing(self) -> bool: return self._player.playing if self._player else False @property def current_time(self) -> float: return self._player.current_time if self._player else 0.0 # ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): t = get_theme() gx, gy, gw, gh = self.get_global_rect() renderer.draw_filled_rect(gx, gy, gw, gh, t.bg_dark) if not self._player: self._draw_empty(renderer, gx, gy, gw, gh) return renderer.push_clip(gx, gy, gw, gh) y = gy y = self._draw_clip_header(renderer, gx, y, gw) y = self._draw_clip_properties(renderer, gx, y, gw) y = self._draw_track_toolbar(renderer, gx, y, gw) self._draw_track_list(renderer, gx, y, gw, gy + gh - y) renderer.pop_clip()
def _draw_empty(self, renderer, x, y, w, h): t = get_theme() msg = "Select a node with AnimationPlayer" tw = renderer.text_width(msg, _FONT_SCALE) renderer.draw_text(msg, x + (w - tw) / 2, y + h / 2 - 7, t.text_dim, _FONT_SCALE) def _draw_clip_header(self, renderer, x, y, w) -> float: """Draw clip selector row. Returns y after header.""" t = get_theme() renderer.draw_filled_rect(x, y, w, _HEADER_HEIGHT, t.bg_darker) names = self._get_clip_names() clip_label = names[self._clip_index] if names else "(no clips)" renderer.draw_text(clip_label, x + _PADDING, y + 9, t.text, _FONT_SCALE) # Speed / loop indicators info = f"Speed: {self._speed:.2f}x" if self._loop: info += " Loop" iw = renderer.text_width(info, _FONT_SCALE) renderer.draw_text(info, x + w - iw - _PADDING, y + 9, t.text_dim, _FONT_SCALE) renderer.draw_filled_rect(x, y + _HEADER_HEIGHT - 1, w, 1, t.border) return y + _HEADER_HEIGHT def _draw_clip_properties(self, renderer, x, y, w) -> float: """Draw clip duration / easing row.""" t = get_theme() clip = self._get_active_clip() if not clip: return y renderer.draw_filled_rect(x, y, w, _SECTION_HEIGHT, t.bg) label = f"Duration: {clip.duration:.2f}s" renderer.draw_text(label, x + _PADDING, y + 7, t.text, _FONT_SCALE) if self._snap_enabled: snap_text = f"Snap: {self._snap_interval:.2f}s" sw = renderer.text_width(snap_text, _FONT_SCALE) renderer.draw_text(snap_text, x + w - sw - _PADDING, y + 7, t.accent, _FONT_SCALE) renderer.draw_filled_rect(x, y + _SECTION_HEIGHT - 1, w, 1, t.border) return y + _SECTION_HEIGHT def _draw_track_toolbar(self, renderer, x, y, w) -> float: """Draw toolbar with Add Track, Copy/Paste buttons.""" t = get_theme() renderer.draw_filled_rect(x, y, w, _SECTION_HEIGHT, t.bg) labels = ["+ Track", "Copy KF", "Paste KF"] if self._onion_skinning: labels.append("Onion [ON]") else: labels.append("Onion") bx = x + _PADDING for label in labels: tw = renderer.text_width(label, _FONT_SCALE) + 12 renderer.draw_filled_rect(bx, y + 3, tw, _SECTION_HEIGHT - 6, t.btn_bg) renderer.draw_text(label, bx + 6, y + 7, t.text, _FONT_SCALE) bx += tw + 4 renderer.draw_filled_rect(x, y + _SECTION_HEIGHT - 1, w, 1, t.border) return y + _SECTION_HEIGHT def _draw_track_list(self, renderer, x, y, w, available_h): """Draw scrollable track list.""" t = get_theme() if not self._track_rows: msg = "No tracks -- add a property track" tw = renderer.text_width(msg, _FONT_SCALE) renderer.draw_text(msg, x + (w - tw) / 2, y + 20, t.text_dim, _FONT_SCALE) return renderer.push_clip(x, y, w, available_h) row_y = y - self._scroll_y for i, row in enumerate(self._track_rows): if row_y + _ROW_HEIGHT < y: row_y += _ROW_HEIGHT continue if row_y > y + available_h: break bg = t.bg_light if i % 2 else t.bg if i == self._selected_track_index: bg = t.selection_bg renderer.draw_filled_rect(x, row_y, w, _ROW_HEIGHT, bg) # Property name renderer.draw_text(row.property_name, x + _PADDING, row_y + 6, t.text, _FONT_SCALE) # Keyframe count kf_text = f"{row.keyframe_count} keys" renderer.text_width(kf_text, _FONT_SCALE) renderer.draw_text(kf_text, x + _LABEL_WIDTH + 20, row_y + 6, t.text_dim, _FONT_SCALE) # Easing label easing_name = self._track_easings.get(row.property_name, "linear") ew = renderer.text_width(easing_name, _FONT_SCALE) renderer.draw_text(easing_name, x + w - ew - _PADDING - 60, row_y + 6, t.accent, _FONT_SCALE) # Remove button renderer.draw_text("x", x + w - _PADDING - 10, row_y + 6, t.error, _FONT_SCALE) row_y += _ROW_HEIGHT renderer.pop_clip() # ==================================================================== # Input handling # ==================================================================== def _on_gui_input(self, event): if not self._player: return gx, gy, gw, gh = self.get_global_rect() if not hasattr(event, "position"): return ex, ey = event.position if not (gx <= ex <= gx + gw and gy <= ey <= gy + gh): return # Track list click track_area_top = gy + _HEADER_HEIGHT + _SECTION_HEIGHT + _SECTION_HEIGHT if ey >= track_area_top and hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1: local_y = ey - track_area_top + self._scroll_y idx = int(local_y / _ROW_HEIGHT) if 0 <= idx < len(self._track_rows): self._selected_track_index = idx # Scroll if hasattr(event, "delta") and ey >= track_area_top: _, dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta) self._scroll_y = max(0.0, self._scroll_y - dy * 20.0) # ==================================================================== # Serialization # ====================================================================
[docs] def get_state(self) -> dict: """Serialize editor panel state.""" return { "clip_index": self._clip_index, "loop": self._loop, "speed": self._speed, "snap_enabled": self._snap_enabled, "snap_interval": self._snap_interval, "onion_skinning": self._onion_skinning, "track_easings": dict(self._track_easings), }
[docs] def restore_state(self, data: dict): """Restore editor panel state.""" self._clip_index = data.get("clip_index", 0) self._loop = data.get("loop", False) self._speed = data.get("speed", 1.0) self._snap_enabled = data.get("snap_enabled", True) self._snap_interval = data.get("snap_interval", 0.1) self._onion_skinning = data.get("onion_skinning", False) self._track_easings = dict(data.get("track_easings", {})) self._refresh_tracks()