Source code for simvx.editor.panels.curve_editor

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