"""Curve Editor Panel -- Bezier curve editor for animation keyframes.
Displays animation tracks as continuous curves with editable tangent handles.
Complements the dope-sheet AnimationPanel with a value-over-time graph view.
Layout:
+----------------------------------------------------------+
| [Track list] zoom_x zoom_y snap snap_interval |
+----------------------------------------------------------+
| value ^ |
| | *---__ |
| | ``--*---__ |
| | ``--* |
| +--------- time -----> |
+----------------------------------------------------------+
"""
from __future__ import annotations
import logging
import math
from enum import StrEnum
from itertools import pairwise
from typing import Any
from simvx.core import AnimationClip, AnimationPlayer, CallableCommand, Control, Signal, Track, Vec2
log = logging.getLogger(__name__)
__all__ = ["CurveEditorPanel", "TangentMode"]
# ============================================================================
# Tangent modes
# ============================================================================
[docs]
class TangentMode(StrEnum):
"""Tangent interpolation mode for a keyframe."""
AUTO = "auto"
LINEAR = "linear"
CONSTANT = "constant"
# ============================================================================
# Colour palette
# ============================================================================
_BG = (0.12, 0.12, 0.12, 1.0)
_GRID_MAJOR = (0.22, 0.22, 0.22, 1.0)
_GRID_MINOR = (0.17, 0.17, 0.17, 1.0)
_AXIS_COLOUR = (0.35, 0.35, 0.35, 1.0)
_TEXT_DIM = (0.50, 0.50, 0.50, 1.0)
_TEXT_COLOUR = (0.85, 0.85, 0.85, 1.0)
_CURVE_COLOURS = [
(0.90, 0.30, 0.30, 1.0), # red
(0.30, 0.85, 0.35, 1.0), # green
(0.30, 0.50, 0.95, 1.0), # blue
(0.95, 0.75, 0.20, 1.0), # yellow
(0.80, 0.40, 0.90, 1.0), # purple
(0.25, 0.85, 0.85, 1.0), # cyan
]
_KEYFRAME_COLOUR = (1.0, 1.0, 1.0, 1.0)
_KEYFRAME_SELECTED = (1.0, 0.85, 0.0, 1.0)
_HANDLE_COLOUR = (0.7, 0.7, 0.7, 0.8)
_HANDLE_LINE_COLOUR = (0.5, 0.5, 0.5, 0.6)
_CONTEXT_BG = (0.18, 0.18, 0.18, 1.0)
_CONTEXT_BORDER = (0.30, 0.30, 0.30, 1.0)
_HEADER_BG = (0.10, 0.10, 0.10, 1.0)
_SEPARATOR = (0.25, 0.25, 0.25, 1.0)
# ============================================================================
# Layout constants
# ============================================================================
_HEADER_HEIGHT = 28.0
_PADDING = 4.0
_FONT_SCALE = 11.0 / 14.0
_SMALL_FONT = 10.0 / 14.0
_KEY_RADIUS = 4.0
_HANDLE_RADIUS = 3.0
_HIT_RADIUS = 8.0
_BEZIER_SEGMENTS = 24
_CONTEXT_ITEM_H = 22.0
_CONTEXT_WIDTH = 160.0
# ============================================================================
# CurveKeyframe — per-keyframe tangent data
# ============================================================================
[docs]
class CurveKeyframe:
"""Extended keyframe data for curve editor with tangent handles.
Tangent values are stored as slopes (dy/dx in value/time space). Positive
tangent = curve going upward to the right.
"""
__slots__ = ("time", "value", "tangent_mode", "tangent_in", "tangent_out")
def __init__(
self,
time: float,
value: float,
tangent_mode: TangentMode = TangentMode.AUTO,
tangent_in: float = 0.0,
tangent_out: float = 0.0,
):
self.time = time
self.value = value
self.tangent_mode = tangent_mode
self.tangent_in = tangent_in
self.tangent_out = tangent_out
# ============================================================================
# CurveEditorPanel
# ============================================================================
[docs]
class CurveEditorPanel(Control):
"""Animation curve editor panel.
Displays animation tracks as continuous bezier curves with editable
tangent handles. Supports zoom, pan, keyframe selection/dragging,
tangent editing, and a right-click context menu.
Args:
editor_state: The central EditorState instance (optional).
"""
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = _BG
self.size = Vec2(800, 300)
# Animation player reference
self._player: AnimationPlayer | None = None
self._player_node = None
# Which clip we are viewing
self._clip_index: int = 0
# View properties
self.zoom_x: float = 100.0 # pixels per second
self.zoom_y: float = 50.0 # pixels per unit value
self.snap_enabled: bool = False
self.snap_interval: float = 0.1
# Pan offset (in pixels, added to the graph origin)
self._pan_x: float = 60.0
self._pan_y: float = 0.0
# Track visibility — maps track property name to bool
self._visible_tracks: dict[str, bool] = {}
# Per-keyframe tangent metadata: (track_name, kf_index) -> CurveKeyframe
self._curve_keyframes: dict[tuple[str, int], CurveKeyframe] = {}
# Selection
self._selected_kf: tuple[str, int] | None = None # (track_name, kf_index)
self._selected_handle: str | None = None # "in" or "out" or None
# Drag state
self._dragging: bool = False
self._drag_start_pos: tuple[float, float] = (0.0, 0.0)
self._drag_start_value: tuple[float, float] = (0.0, 0.0)
# Pan state (middle mouse)
self._panning: bool = False
self._pan_start: tuple[float, float] = (0.0, 0.0)
self._pan_start_offset: tuple[float, float] = (0.0, 0.0)
# Context menu
self._context_visible: bool = False
self._context_pos: tuple[float, float] = (0.0, 0.0)
self._context_items: list[tuple[str, str]] = []
self._context_time: float = 0.0 # time where the right-click happened
# Signals
self.curve_changed = Signal()
self.keyframe_selected = Signal()
# ====================================================================
# Lifecycle
# ====================================================================
[docs]
def ready(self):
"""Connect to editor state signals."""
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_kf = None
self._selected_handle = None
self._context_visible = False
self._rebuild_curve_keyframes()
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 = CurveEditorPanel._find_player(child)
if result:
return result
return None
# ====================================================================
# Clip helpers
# ====================================================================
def _get_clip_names(self) -> list[str]:
if not self._player:
return []
return sorted(self._player.clips)
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))]
# ====================================================================
# Curve keyframe management
# ====================================================================
def _rebuild_curve_keyframes(self):
"""Rebuild CurveKeyframe metadata from the active clip's tracks."""
self._curve_keyframes.clear()
self._visible_tracks.clear()
clip = self._get_active_clip()
if not clip:
return
for track_name, track in clip.tracks.items():
self._visible_tracks[track_name] = True
for i, (t, v) in enumerate(track.keyframes):
fv = self._to_float(v)
ck = CurveKeyframe(t, fv, TangentMode.AUTO)
self._auto_tangent(ck, track, i)
self._curve_keyframes[(track_name, i)] = ck
def _get_curve_kf(self, track_name: str, index: int) -> CurveKeyframe | None:
"""Get or create a CurveKeyframe for the given track keyframe."""
key = (track_name, index)
if key in self._curve_keyframes:
return self._curve_keyframes[key]
clip = self._get_active_clip()
if not clip or track_name not in clip.tracks:
return None
track = clip.tracks[track_name]
if index < 0 or index >= len(track.keyframes):
return None
t, v = track.keyframes[index]
ck = CurveKeyframe(t, self._to_float(v), TangentMode.AUTO)
self._auto_tangent(ck, track, index)
self._curve_keyframes[key] = ck
return ck
def _auto_tangent(self, ck: CurveKeyframe, track: Track, index: int):
"""Calculate auto tangents using Catmull-Rom style slopes."""
if ck.tangent_mode != TangentMode.AUTO:
return
kfs = track.keyframes
if len(kfs) < 2:
ck.tangent_in = 0.0
ck.tangent_out = 0.0
return
if index == 0:
t0, v0 = kfs[0]
t1, v1 = kfs[1]
dt = t1 - t0
slope = (self._to_float(v1) - self._to_float(v0)) / dt if dt > 0 else 0.0
ck.tangent_in = slope
ck.tangent_out = slope
elif index == len(kfs) - 1:
t0, v0 = kfs[-2]
t1, v1 = kfs[-1]
dt = t1 - t0
slope = (self._to_float(v1) - self._to_float(v0)) / dt if dt > 0 else 0.0
ck.tangent_in = slope
ck.tangent_out = slope
else:
t_prev, v_prev = kfs[index - 1]
t_next, v_next = kfs[index + 1]
dt = t_next - t_prev
slope = (self._to_float(v_next) - self._to_float(v_prev)) / dt if dt > 0 else 0.0
ck.tangent_in = slope
ck.tangent_out = slope
@staticmethod
def _to_float(value) -> float:
"""Convert a keyframe value to a single float for curve display."""
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
return 0.0
if hasattr(value, "__len__"):
try:
return float(value[0]) if len(value) > 0 else 0.0
except (ValueError, TypeError):
return 0.0
return 0.0
# ====================================================================
# Coordinate conversion
# ====================================================================
def _graph_origin(self) -> tuple[float, float]:
"""Return the pixel position of the graph origin (time=0, value=0)."""
gx, gy, _gw, gh = self.get_global_rect()
ox = gx + self._pan_x
oy = gy + _HEADER_HEIGHT + (gh - _HEADER_HEIGHT) * 0.5 + self._pan_y
return ox, oy
def _time_to_x(self, t: float) -> float:
ox, _ = self._graph_origin()
return ox + t * self.zoom_x
def _value_to_y(self, v: float) -> float:
_, oy = self._graph_origin()
return oy - v * self.zoom_y # y-up in value space, y-down in screen
def _x_to_time(self, x: float) -> float:
ox, _ = self._graph_origin()
return (x - ox) / self.zoom_x if self.zoom_x != 0 else 0.0
def _y_to_value(self, y: float) -> float:
_, oy = self._graph_origin()
return (oy - y) / self.zoom_y if self.zoom_y != 0 else 0.0
def _snap_time(self, t: float) -> float:
"""Snap time to grid if snapping is enabled."""
if not self.snap_enabled or self.snap_interval <= 0:
return t
return round(t / self.snap_interval) * self.snap_interval
def _snap_value(self, v: float) -> float:
"""Snap value to grid if snapping is enabled."""
if not self.snap_enabled or self.snap_interval <= 0:
return v
return round(v / self.snap_interval) * self.snap_interval
# ====================================================================
# Graph area rect
# ====================================================================
def _graph_rect(self) -> tuple[float, float, float, float]:
"""The drawable graph area (below header)."""
gx, gy, gw, gh = self.get_global_rect()
return gx, gy + _HEADER_HEIGHT, gw, gh - _HEADER_HEIGHT
# ====================================================================
# Drawing
# ====================================================================
[docs]
def draw(self, renderer):
gx, gy, gw, gh = self.get_global_rect()
renderer.draw_filled_rect(gx, gy, gw, gh, _BG)
if not self._player:
msg = "Select an AnimationPlayer to edit curves"
tw = renderer.text_width(msg, _FONT_SCALE)
renderer.draw_text_coloured(msg, gx + (gw - tw) / 2, gy + (gh - 14) / 2, _FONT_SCALE, _TEXT_DIM)
return
clip = self._get_active_clip()
self._draw_header(renderer, gx, gy, gw)
grx, gry, grw, grh = self._graph_rect()
renderer.push_clip(grx, gry, grw, grh)
self._draw_grid(renderer, grx, gry, grw, grh)
self._draw_axes(renderer, grx, gry, grw, grh)
if clip:
self._draw_curves(renderer, clip, grx, gry, grw, grh)
self._draw_keyframes(renderer, clip, grx, gry, grw, grh)
renderer.pop_clip()
if self._context_visible:
self._draw_context_menu(renderer)
def _draw_header(self, renderer, gx, gy, gw):
"""Draw the top header with clip name and track toggles."""
renderer.draw_filled_rect(gx, gy, gw, _HEADER_HEIGHT, _HEADER_BG)
renderer.draw_filled_rect(gx, gy + _HEADER_HEIGHT - 1, gw, 1, _SEPARATOR)
cx = gx + _PADDING
cy = gy + (_HEADER_HEIGHT - 11) / 2
clip_name = self._get_active_clip_name() or "(no clip)"
label = f"Curve: [{clip_name}]"
renderer.draw_text_coloured(label, cx, cy, _FONT_SCALE, _TEXT_COLOUR)
cx += renderer.text_width(label, _FONT_SCALE) + 12
# Track visibility toggles
clip = self._get_active_clip()
if clip:
colour_idx = 0
for track_name in sorted(clip.tracks):
vis = self._visible_tracks.get(track_name, True)
col = _CURVE_COLOURS[colour_idx % len(_CURVE_COLOURS)] if vis else _TEXT_DIM
renderer.draw_text_coloured(track_name, cx, cy, _SMALL_FONT, col)
cx += renderer.text_width(track_name, _SMALL_FONT) + 8
colour_idx += 1
# Snap indicator (right side)
snap_str = f"Snap: {'ON' if self.snap_enabled else 'OFF'} ({self.snap_interval:.2f})"
sw = renderer.text_width(snap_str, _SMALL_FONT)
renderer.draw_text_coloured(snap_str, gx + gw - sw - _PADDING, cy, _SMALL_FONT, _TEXT_DIM)
def _draw_grid(self, renderer, grx, gry, grw, grh):
"""Draw background grid lines."""
# Determine visible time range
t_start = self._x_to_time(grx)
t_end = self._x_to_time(grx + grw)
# Major time gridlines (1s intervals)
t_first = math.floor(t_start)
t_last = math.ceil(t_end) + 1
for sec in range(int(t_first), int(t_last)):
x = self._time_to_x(float(sec))
if grx <= x <= grx + grw:
renderer.draw_line_coloured(x, gry, x, gry + grh, _GRID_MAJOR)
# Minor time gridlines (0.25s intervals)
minor_step = 0.25
minor_first = math.floor(t_start / minor_step) * minor_step
t = minor_first
while t <= t_end:
x = self._time_to_x(t)
if grx <= x <= grx + grw and abs(t - round(t)) > 0.01:
renderer.draw_line_coloured(x, gry, x, gry + grh, _GRID_MINOR)
t += minor_step
# Value gridlines
v_top = self._y_to_value(gry)
v_bottom = self._y_to_value(gry + grh)
v_min = min(v_top, v_bottom)
v_max = max(v_top, v_bottom)
# Choose step so we get 5-15 gridlines
v_range = v_max - v_min
if v_range > 0:
raw_step = v_range / 10.0
mag = 10.0 ** math.floor(math.log10(raw_step)) if raw_step > 0 else 1.0
step = mag
if raw_step / mag > 5:
step = mag * 5
elif raw_step / mag > 2:
step = mag * 2
else:
step = 1.0
v = math.floor(v_min / step) * step
while v <= v_max:
y = self._value_to_y(v)
if gry <= y <= gry + grh:
is_major = abs(v) < 1e-6 or abs(v - round(v)) < 1e-6
renderer.draw_line_coloured(grx, y, grx + grw, y, _GRID_MAJOR if is_major else _GRID_MINOR)
v += step
def _draw_axes(self, renderer, grx, gry, grw, grh):
"""Draw time and value axis lines plus labels."""
# Time axis (value=0)
y0 = self._value_to_y(0.0)
if gry <= y0 <= gry + grh:
renderer.draw_line_coloured(grx, y0, grx + grw, y0, _AXIS_COLOUR)
# Value axis (time=0)
x0 = self._time_to_x(0.0)
if grx <= x0 <= grx + grw:
renderer.draw_line_coloured(x0, gry, x0, gry + grh, _AXIS_COLOUR)
# Time labels along bottom
t_start = self._x_to_time(grx)
t_end = self._x_to_time(grx + grw)
t_first = int(math.floor(t_start))
t_last = int(math.ceil(t_end)) + 1
label_y = gry + grh - 12
for sec in range(max(0, t_first), t_last):
x = self._time_to_x(float(sec))
if grx <= x <= grx + grw - 20:
renderer.draw_text_coloured(f"{sec}s", x + 2, label_y, _SMALL_FONT, _TEXT_DIM)
# Value labels along left edge
v_top = self._y_to_value(gry)
v_bottom = self._y_to_value(gry + grh)
v_min, v_max = min(v_top, v_bottom), max(v_top, v_bottom)
v_range = v_max - v_min
if v_range > 0:
raw_step = v_range / 8.0
mag = 10.0 ** math.floor(math.log10(raw_step)) if raw_step > 0 else 1.0
step = mag
if raw_step / mag > 5:
step = mag * 5
elif raw_step / mag > 2:
step = mag * 2
else:
step = 1.0
v = math.floor(v_min / step) * step
while v <= v_max:
y = self._value_to_y(v)
if gry + 5 <= y <= gry + grh - 5:
renderer.draw_text_coloured(f"{v:.1f}", grx + 2, y - 5, _SMALL_FONT, _TEXT_DIM)
v += step
def _draw_curves(self, renderer, clip: AnimationClip, grx, gry, grw, grh):
"""Draw bezier curves for each visible track."""
colour_idx = 0
for track_name in sorted(clip.tracks):
if not self._visible_tracks.get(track_name, True):
colour_idx += 1
continue
track = clip.tracks[track_name]
colour = _CURVE_COLOURS[colour_idx % len(_CURVE_COLOURS)]
self._draw_single_curve(renderer, track_name, track, colour, grx, gry, grw, grh)
colour_idx += 1
def _draw_single_curve(self, renderer, track_name: str, track: Track, colour, grx, gry, grw, grh):
"""Draw a single track's bezier curve."""
kfs = track.keyframes
if len(kfs) < 2:
# Single keyframe: draw a horizontal line
if kfs:
fv = self._to_float(kfs[0][1])
y = self._value_to_y(fv)
renderer.draw_line_coloured(grx, y, grx + grw, y, colour)
return
for i, (_kf0, _kf1) in enumerate(pairwise(kfs)):
ck0 = self._get_curve_kf(track_name, i)
ck1 = self._get_curve_kf(track_name, i + 1)
if not ck0 or not ck1:
continue
# For constant mode, draw a step
if ck0.tangent_mode == TangentMode.CONSTANT:
x0 = self._time_to_x(ck0.time)
x1 = self._time_to_x(ck1.time)
y0 = self._value_to_y(ck0.value)
y1 = self._value_to_y(ck1.value)
renderer.draw_line_coloured(x0, y0, x1, y0, colour)
renderer.draw_line_coloured(x1, y0, x1, y1, colour)
continue
# For linear mode, draw a straight line
if ck0.tangent_mode == TangentMode.LINEAR and ck1.tangent_mode == TangentMode.LINEAR:
x0 = self._time_to_x(ck0.time)
x1 = self._time_to_x(ck1.time)
y0 = self._value_to_y(ck0.value)
y1 = self._value_to_y(ck1.value)
renderer.draw_line_coloured(x0, y0, x1, y1, colour)
continue
# Bezier curve: compute control points from tangent slopes
dt = ck1.time - ck0.time
if dt <= 0:
continue
handle_dt = dt / 3.0
# P0
p0x = self._time_to_x(ck0.time)
p0y = self._value_to_y(ck0.value)
# P1 (out handle of ck0)
p1x = self._time_to_x(ck0.time + handle_dt)
p1y = self._value_to_y(ck0.value + ck0.tangent_out * handle_dt)
# P2 (in handle of ck1)
p2x = self._time_to_x(ck1.time - handle_dt)
p2y = self._value_to_y(ck1.value - ck1.tangent_in * handle_dt)
# P3
p3x = self._time_to_x(ck1.time)
p3y = self._value_to_y(ck1.value)
# Draw bezier as line segments
prev_x, prev_y = p0x, p0y
for seg in range(1, _BEZIER_SEGMENTS + 1):
t = seg / _BEZIER_SEGMENTS
inv = 1.0 - t
# Cubic bezier formula
bx = inv**3 * p0x + 3 * inv**2 * t * p1x + 3 * inv * t**2 * p2x + t**3 * p3x
by = inv**3 * p0y + 3 * inv**2 * t * p1y + 3 * inv * t**2 * p2y + t**3 * p3y
renderer.draw_line_coloured(prev_x, prev_y, bx, by, colour)
prev_x, prev_y = bx, by
def _draw_keyframes(self, renderer, clip: AnimationClip, grx, gry, grw, grh):
"""Draw keyframe dots and tangent handles."""
colour_idx = 0
for track_name in sorted(clip.tracks):
if not self._visible_tracks.get(track_name, True):
colour_idx += 1
continue
track = clip.tracks[track_name]
curve_colour = _CURVE_COLOURS[colour_idx % len(_CURVE_COLOURS)]
for i, (kf_time, kf_val) in enumerate(track.keyframes):
ck = self._get_curve_kf(track_name, i)
if not ck:
continue
kx = self._time_to_x(ck.time)
ky = self._value_to_y(ck.value)
is_selected = self._selected_kf == (track_name, i)
dot_colour = _KEYFRAME_SELECTED if is_selected else _KEYFRAME_COLOUR
# Draw keyframe dot as small filled rect (diamond approx)
r = _KEY_RADIUS
renderer.draw_filled_rect(kx - r, ky - r, r * 2, r * 2, dot_colour)
# Tangent handles (only for selected keyframe)
if is_selected and ck.tangent_mode != TangentMode.CONSTANT:
dt_seg = 0.3 # visual handle length in time units
# In handle
if i > 0:
hx = self._time_to_x(ck.time - dt_seg)
hy = self._value_to_y(ck.value - ck.tangent_in * dt_seg)
renderer.draw_line_coloured(kx, ky, hx, hy, _HANDLE_LINE_COLOUR)
hr = _HANDLE_RADIUS
renderer.draw_filled_rect(hx - hr, hy - hr, hr * 2, hr * 2, _HANDLE_COLOUR)
# Out handle
if i < len(track.keyframes) - 1:
hx = self._time_to_x(ck.time + dt_seg)
hy = self._value_to_y(ck.value + ck.tangent_out * dt_seg)
renderer.draw_line_coloured(kx, ky, hx, hy, _HANDLE_LINE_COLOUR)
hr = _HANDLE_RADIUS
renderer.draw_filled_rect(hx - hr, hy - hr, hr * 2, hr * 2, _HANDLE_COLOUR)
colour_idx += 1
def _draw_context_menu(self, renderer):
"""Draw the right-click context menu."""
if not self._context_items:
return
mx, my = self._context_pos
menu_h = len(self._context_items) * _CONTEXT_ITEM_H + 4
renderer.draw_filled_rect(mx, my, _CONTEXT_WIDTH, menu_h, _CONTEXT_BG)
renderer.draw_rect_coloured(mx, my, _CONTEXT_WIDTH, menu_h, _CONTEXT_BORDER)
for i, (label, _action) in enumerate(self._context_items):
iy = my + 2 + i * _CONTEXT_ITEM_H
renderer.draw_text_coloured(label, mx + 8, iy + 4, _SMALL_FONT, _TEXT_COLOUR)
# ====================================================================
# Input handling
# ====================================================================
def _on_gui_input(self, event):
if not self.is_point_inside(event.position):
if self._context_visible:
self._context_visible = False
return
# Close context menu on any click outside it
if event.button and event.pressed and self._context_visible:
if not self._is_point_in_context_menu(event.position):
self._context_visible = False
# Don't consume the event, let it fall through
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]
# Scroll wheel: zoom
if event.key in ("scroll_up", "scroll_down"):
self._handle_zoom(event, px, py)
return
# Context menu click
if self._context_visible and event.button == 1 and event.pressed:
self._handle_context_click(px, py)
return
# Middle mouse: pan
if event.button == 2:
self._handle_pan(event, px, py)
return
# Right click: context menu
if event.button == 3 and event.pressed:
self._show_context_menu(px, py)
return
# Left click: select/drag keyframes or handles
if event.button == 1:
self._handle_left_click(event, px, py)
return
# Mouse move (drag)
if event.button == 0 and not event.pressed:
self._handle_mouse_move(px, py)
def _handle_zoom(self, event, px: float, py: float):
"""Zoom in/out on scroll wheel."""
factor = 1.15 if event.key == "scroll_up" else 1.0 / 1.15
# Zoom around the mouse position
time_at_mouse = self._x_to_time(px)
value_at_mouse = self._y_to_value(py)
self.zoom_x = max(10.0, min(2000.0, self.zoom_x * factor))
self.zoom_y = max(5.0, min(1000.0, self.zoom_y * factor))
# Adjust pan so the point under the mouse stays put
new_x = self._time_to_x(time_at_mouse)
new_y = self._value_to_y(value_at_mouse)
self._pan_x += px - new_x
self._pan_y += py - new_y
def _handle_pan(self, event, px: float, py: float):
"""Middle-mouse pan."""
if event.pressed:
self._panning = True
self._pan_start = (px, py)
self._pan_start_offset = (self._pan_x, self._pan_y)
else:
if self._panning:
dx = px - self._pan_start[0]
dy = py - self._pan_start[1]
self._pan_x = self._pan_start_offset[0] + dx
self._pan_y = self._pan_start_offset[1] + dy
self._panning = False
def _handle_left_click(self, event, px: float, py: float):
"""Handle left mouse click for selection and dragging."""
if event.pressed:
# Check if clicking on a tangent handle first
handle = self._hit_test_handle(px, py)
if handle:
track_name, kf_idx, which = handle
self._selected_kf = (track_name, kf_idx)
self._selected_handle = which
self._dragging = True
self._drag_start_pos = (px, py)
ck = self._get_curve_kf(track_name, kf_idx)
if ck:
self._drag_start_value = (ck.tangent_in if which == "in" else ck.tangent_out, 0.0)
self.keyframe_selected.emit()
return
# Check if clicking on a keyframe
hit = self._hit_test_keyframe(px, py)
if hit:
track_name, kf_idx = hit
self._selected_kf = (track_name, kf_idx)
self._selected_handle = None
self._dragging = True
self._drag_start_pos = (px, py)
ck = self._get_curve_kf(track_name, kf_idx)
if ck:
self._drag_start_value = (ck.time, ck.value)
self.keyframe_selected.emit()
return
# Click on empty space: deselect
self._selected_kf = None
self._selected_handle = None
self.keyframe_selected.emit()
else:
# Release: finish drag
self._dragging = False
def _handle_mouse_move(self, px: float, py: float):
"""Handle mouse movement for dragging."""
if self._panning:
dx = px - self._pan_start[0]
dy = py - self._pan_start[1]
self._pan_x = self._pan_start_offset[0] + dx
self._pan_y = self._pan_start_offset[1] + dy
return
if not self._dragging or not self._selected_kf:
return
track_name, kf_idx = self._selected_kf
if self._selected_handle:
# Dragging a tangent handle
self._drag_tangent_handle(track_name, kf_idx, self._selected_handle, px, py)
else:
# Dragging a keyframe
self._drag_keyframe(track_name, kf_idx, px, py)
def _drag_keyframe(self, track_name: str, kf_idx: int, px: float, py: float):
"""Move a keyframe to a new time/value position."""
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 = self._snap_time(self._x_to_time(px))
new_value = self._snap_value(self._y_to_value(py))
new_time = max(0.0, new_time)
if clip.duration > 0:
new_time = min(clip.duration, new_time)
# Update the keyframe
old_time, old_value = track.keyframes[kf_idx]
track.keyframes[kf_idx] = (new_time, new_value)
track.keyframes.sort(key=lambda kf: kf[0])
# Find new index after sort
new_idx = next(
(j for j, (t, v) in enumerate(track.keyframes) if t == new_time and v == new_value),
kf_idx,
)
self._selected_kf = (track_name, new_idx)
# Update curve keyframe
ck = self._get_curve_kf(track_name, new_idx)
if ck:
ck.time = new_time
ck.value = new_value
self._auto_tangent(ck, track, new_idx)
# Rebuild metadata since indices shifted
self._rebuild_curve_keyframes()
self.curve_changed.emit()
def _drag_tangent_handle(self, track_name: str, kf_idx: int, which: str, px: float, py: float):
"""Adjust a tangent handle's slope."""
ck = self._get_curve_kf(track_name, kf_idx)
if not ck:
return
kx = self._time_to_x(ck.time)
ky = self._value_to_y(ck.value)
dx = px - kx
dy = py - ky
# Convert pixel delta to time/value delta
dt = dx / self.zoom_x if self.zoom_x != 0 else 0.0
dv = -dy / self.zoom_y if self.zoom_y != 0 else 0.0 # y-up
# Slope = dv/dt
slope = dv / dt if abs(dt) > 1e-6 else 0.0
if ck.tangent_mode == TangentMode.AUTO:
ck.tangent_mode = TangentMode.AUTO # Keep auto but allow override
if which == "in":
ck.tangent_in = slope
else:
ck.tangent_out = slope
self.curve_changed.emit()
# ====================================================================
# Hit testing
# ====================================================================
def _hit_test_keyframe(self, px: float, py: float) -> tuple[str, int] | None:
"""Return (track_name, kf_index) if a keyframe is near (px, py)."""
clip = self._get_active_clip()
if not clip:
return None
best_dist = _HIT_RADIUS
best = None
for track_name in clip.tracks:
if not self._visible_tracks.get(track_name, True):
continue
track = clip.tracks[track_name]
for i, (t, v) in enumerate(track.keyframes):
fv = self._to_float(v)
kx = self._time_to_x(t)
ky = self._value_to_y(fv)
dist = math.hypot(px - kx, py - ky)
if dist < best_dist:
best_dist = dist
best = (track_name, i)
return best
def _hit_test_handle(self, px: float, py: float) -> tuple[str, int, str] | None:
"""Return (track_name, kf_index, 'in'|'out') if a tangent handle is near (px, py)."""
if not self._selected_kf:
return None
track_name, kf_idx = self._selected_kf
ck = self._get_curve_kf(track_name, kf_idx)
if not ck or ck.tangent_mode == TangentMode.CONSTANT:
return None
clip = self._get_active_clip()
if not clip or track_name not in clip.tracks:
return None
track = clip.tracks[track_name]
kx = self._time_to_x(ck.time)
ky = self._value_to_y(ck.value)
dt_seg = 0.3
# In handle
if kf_idx > 0:
hx = self._time_to_x(ck.time - dt_seg)
hy = self._value_to_y(ck.value - ck.tangent_in * dt_seg)
if math.hypot(px - hx, py - hy) < _HIT_RADIUS:
return (track_name, kf_idx, "in")
# Out handle
if kf_idx < len(track.keyframes) - 1:
hx = self._time_to_x(ck.time + dt_seg)
hy = self._value_to_y(ck.value + ck.tangent_out * dt_seg)
if math.hypot(px - hx, py - hy) < _HIT_RADIUS:
return (track_name, kf_idx, "out")
return None
def _is_point_in_context_menu(self, pos) -> bool:
if not self._context_visible or not self._context_items:
return False
px = pos.x if hasattr(pos, "x") else pos[0]
py = pos.y if hasattr(pos, "y") else pos[1]
mx, my = self._context_pos
menu_h = len(self._context_items) * _CONTEXT_ITEM_H + 4
return mx <= px <= mx + _CONTEXT_WIDTH and my <= py <= my + menu_h
# ====================================================================
# Context menu
# ====================================================================
def _show_context_menu(self, px: float, py: float):
"""Show context menu at the given screen position."""
self._context_pos = (px, py)
self._context_time = self._x_to_time(px)
self._context_visible = True
items = [("Add Keyframe", "add_keyframe")]
if self._selected_kf:
items.append(("Delete Keyframe", "delete_keyframe"))
items.append(("---", "separator"))
items.append(("Tangent: Auto", "tangent_auto"))
items.append(("Tangent: Linear", "tangent_linear"))
items.append(("Tangent: Constant", "tangent_constant"))
self._context_items = items
def _handle_context_click(self, px: float, py: float):
"""Handle a click inside the context menu."""
mx, my = self._context_pos
rel_y = py - my - 2
idx = int(rel_y / _CONTEXT_ITEM_H)
if 0 <= idx < len(self._context_items):
label, action = self._context_items[idx]
if action == "separator":
return
self._execute_context_action(action)
self._context_visible = False
def _execute_context_action(self, action: str):
"""Execute a context menu action."""
if action == "add_keyframe":
self._add_keyframe_at_time(self._context_time)
elif action == "delete_keyframe":
self._delete_selected_keyframe()
elif action == "tangent_auto":
self._set_selected_tangent_mode(TangentMode.AUTO)
elif action == "tangent_linear":
self._set_selected_tangent_mode(TangentMode.LINEAR)
elif action == "tangent_constant":
self._set_selected_tangent_mode(TangentMode.CONSTANT)
# ====================================================================
# Keyframe operations
# ====================================================================
[docs]
def add_keyframe(self, track_name: str, time: float, value: float) -> bool:
"""Add a keyframe to the named track at the given time/value.
Returns True on success, False if the track doesn't exist.
"""
clip = self._get_active_clip()
if not clip or track_name not in clip.tracks:
return False
track = clip.tracks[track_name]
time = self._snap_time(time)
def _do():
track.add_keyframe(time, value)
def _undo():
track.keyframes = [(t, v) for t, v in track.keyframes if not (abs(t - time) < 1e-9 and v == value)]
if self.state and hasattr(self.state, "undo_stack"):
cmd = CallableCommand(_do, _undo, f"Add keyframe at t={time:.2f}")
self.state.undo_stack.push(cmd)
else:
_do()
self._rebuild_curve_keyframes()
self.curve_changed.emit()
return True
[docs]
def delete_keyframe(self, track_name: str, kf_index: int) -> bool:
"""Delete the keyframe at the given index from the named track.
Returns True on success.
"""
clip = self._get_active_clip()
if not clip or track_name not in clip.tracks:
return False
track = clip.tracks[track_name]
if kf_index < 0 or kf_index >= len(track.keyframes):
return False
old_kf = track.keyframes[kf_index]
def _do():
if kf_index < len(track.keyframes):
track.keyframes.pop(kf_index)
def _undo():
track.keyframes.insert(kf_index, old_kf)
track.keyframes.sort(key=lambda kf: kf[0])
if self.state and hasattr(self.state, "undo_stack"):
cmd = CallableCommand(_do, _undo, f"Delete keyframe {track_name}[{kf_index}]")
self.state.undo_stack.push(cmd)
else:
_do()
if self._selected_kf and self._selected_kf[0] == track_name:
self._selected_kf = None
self._selected_handle = None
self._rebuild_curve_keyframes()
self.curve_changed.emit()
return True
[docs]
def set_tangent_mode(self, track_name: str, kf_index: int, mode: TangentMode) -> bool:
"""Set the tangent mode for a specific keyframe.
Returns True on success.
"""
ck = self._get_curve_kf(track_name, kf_index)
if not ck:
return False
old_mode = ck.tangent_mode
ck.tangent_mode = mode
# Recalculate tangents for auto mode
if mode == TangentMode.AUTO:
clip = self._get_active_clip()
if clip and track_name in clip.tracks:
self._auto_tangent(ck, clip.tracks[track_name], kf_index)
elif mode == TangentMode.LINEAR:
# Set tangents to match linear interpolation
clip = self._get_active_clip()
if clip and track_name in clip.tracks:
track = clip.tracks[track_name]
kfs = track.keyframes
if kf_index > 0:
dt = ck.time - kfs[kf_index - 1][0]
if dt > 0:
ck.tangent_in = (ck.value - self._to_float(kfs[kf_index - 1][1])) / dt
if kf_index < len(kfs) - 1:
dt = kfs[kf_index + 1][0] - ck.time
if dt > 0:
ck.tangent_out = (self._to_float(kfs[kf_index + 1][1]) - ck.value) / dt
self.curve_changed.emit()
return True
def _add_keyframe_at_time(self, time: float):
"""Add a keyframe at the given time on the first visible track."""
clip = self._get_active_clip()
if not clip:
return
# Find first visible track
for track_name in sorted(clip.tracks):
if not self._visible_tracks.get(track_name, True):
continue
# Evaluate track at this time to get the interpolated value
track = clip.tracks[track_name]
value = track.evaluate(time)
fv = self._to_float(value) if value is not None else 0.0
self.add_keyframe(track_name, time, fv)
return
def _delete_selected_keyframe(self):
"""Delete the currently selected keyframe."""
if not self._selected_kf:
return
track_name, kf_idx = self._selected_kf
self.delete_keyframe(track_name, kf_idx)
def _set_selected_tangent_mode(self, mode: TangentMode):
"""Set tangent mode on the currently selected keyframe."""
if not self._selected_kf:
return
track_name, kf_idx = self._selected_kf
self.set_tangent_mode(track_name, kf_idx, mode)
# ====================================================================
# Utility
# ====================================================================
[docs]
def get_visible_tracks(self) -> list[str]:
"""Return list of currently visible track names."""
return [name for name, vis in self._visible_tracks.items() if vis]
[docs]
def set_track_visible(self, track_name: str, visible: bool):
"""Set visibility of a track."""
self._visible_tracks[track_name] = visible
[docs]
def get_selected_keyframe(self) -> tuple[str, int] | None:
"""Return (track_name, kf_index) of the selected keyframe, or None."""
return self._selected_kf
[docs]
def frame_all(self):
"""Adjust zoom/pan to fit all keyframes in view."""
clip = self._get_active_clip()
if not clip:
return
t_min, t_max = float("inf"), float("-inf")
v_min, v_max = float("inf"), float("-inf")
for track in clip.tracks.values():
for t, v in track.keyframes:
fv = self._to_float(v)
t_min = min(t_min, t)
t_max = max(t_max, t)
v_min = min(v_min, fv)
v_max = max(v_max, fv)
if t_min == float("inf"):
return
# Add margin
t_range = max(t_max - t_min, 0.5)
v_range = max(v_max - v_min, 1.0)
grx, gry, grw, grh = self._graph_rect()
self.zoom_x = max(10.0, (grw - 40) / t_range)
self.zoom_y = max(5.0, (grh - 40) / v_range)
# Center the content
mid_t = (t_min + t_max) / 2.0
mid_v = (v_min + v_max) / 2.0
self._pan_x = grw / 2.0 - mid_t * self.zoom_x
self._pan_y = -(mid_v * self.zoom_y) # Offset from center