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