host.controller_config.curve_editor_widget

Embeddable, resizable curve editor widget for the Action Editor tab.

Supports seven modes:

  • "spline" — interactive cubic hermite spline editing
  • "segment" — interactive piecewise-linear editing
  • "raw" — read-only visualization of y = x
  • "scaled" — visualization with draggable scale handle
  • "squared" — visualization with draggable scale handle (quadratic)
  • "threshold" — step function with draggable threshold for BOOLEAN_TRIGGER
  • "virtual_analog" — ramp preview with draggable target/rest handles
  • None — inactive (button input type or no action selected)
   1"""Embeddable, resizable curve editor widget for the Action Editor tab.
   2
   3Supports seven modes:
   4  - "spline"         — interactive cubic hermite spline editing
   5  - "segment"        — interactive piecewise-linear editing
   6  - "raw"            — read-only visualization of y = x
   7  - "scaled"         — visualization with draggable scale handle
   8  - "squared"        — visualization with draggable scale handle (quadratic)
   9  - "threshold"      — step function with draggable threshold for BOOLEAN_TRIGGER
  10  - "virtual_analog" — ramp preview with draggable target/rest handles
  11  - None             — inactive (button input type or no action selected)
  12"""
  13
  14import math
  15import tkinter as tk
  16from copy import deepcopy
  17from tkinter import ttk, filedialog, messagebox
  18
  19import yaml
  20
  21from host.controller_config.colors import (
  22    BG_INACTIVE,
  23    BG_WHITE,
  24    CURVE_LINE,
  25    ENDPOINT_FILL,
  26    GRID_AXIS,
  27    GRID_MAJOR,
  28    GRID_MINOR,
  29    HANDLE_FILL,
  30    HANDLE_LINE,
  31    LABEL_COLOR,
  32    MIRROR_LINE,
  33    POINT_FILL,
  34    POINT_OUTLINE,
  35)
  36from host.controller_config.editor_utils import UndoStack, nice_grid_step
  37from utils.controller.model import (
  38    ActionDefinition,
  39    EventTriggerMode,
  40    EXTRA_SEGMENT_POINTS,
  41    EXTRA_SPLINE_POINTS,
  42    EXTRA_VA_ACCELERATION,
  43    EXTRA_VA_NEGATIVE_ACCELERATION,
  44    EXTRA_VA_NEGATIVE_RAMP_RATE,
  45    EXTRA_VA_RAMP_RATE,
  46    EXTRA_VA_REST_VALUE,
  47    EXTRA_VA_TARGET_VALUE,
  48    EXTRA_VA_ZERO_VEL_ON_RELEASE,
  49    EXTRA_VA_BUTTON_MODE,
  50    InputType,
  51    TRIGGER_INPUTS,
  52)
  53from utils.math.curves import (
  54    evaluate_segments,
  55    evaluate_spline,
  56    default_spline_points,
  57    default_segment_points,
  58    numerical_slope,
  59)
  60
  61
  62# ---------------------------------------------------------------------------
  63# Constants
  64# ---------------------------------------------------------------------------
  65
  66_MIN_X_GAP = 0.04
  67_POINT_RADIUS = 7
  68_HANDLE_RADIUS = 5
  69_CURVE_SAMPLES_PER_SEG = 80
  70_VIS_SAMPLES = 200          # samples for visualization curves
  71
  72# File-specific colors (shared palette in colors.py)
  73_DEADBAND_FILL = "#e0e0e0"
  74_SCALE_HANDLE = "#d08020"
  75_SCALE_HANDLE_OUTLINE = "#906010"
  76_TRACKER_FILL = "#ff6600"
  77_TRACKER_RADIUS = 4
  78_TRACKER_TAG = "tracker"
  79_THRESHOLD_LINE = "#c03030"
  80_THRESHOLD_HANDLE = "#e04040"
  81_THRESHOLD_HANDLE_OUTLINE = "#901010"
  82
  83
  84# ---------------------------------------------------------------------------
  85# CurveEditorWidget
  86# ---------------------------------------------------------------------------
  87
  88class CurveEditorWidget(ttk.Frame):
  89    """Embeddable curve editor supporting spline, segment, and visualization
  90    modes.
  91
  92    Replaces the Phase 2 placeholder in the lower-left pane of the
  93    Action Editor tab.
  94    """
  95
  96    def __init__(self, parent, *,
  97                 on_before_change=None,
  98                 on_curve_changed=None,
  99                 get_other_curves=None,
 100                 get_advanced_flags=None):
 101        super().__init__(parent)
 102
 103        self._on_before_change = on_before_change
 104        self._on_curve_changed = on_curve_changed
 105        self._get_other_curves = get_other_curves
 106        self._get_advanced_flags = get_advanced_flags or (
 107            lambda: {"splines": True, "nonmono": True})
 108
 109        self._action: ActionDefinition | None = None
 110        self._qname: str | None = None
 111        self._mode: str | None = None  # spline/segment/raw/scaled/squared
 112        self._points: list[dict] = []
 113
 114        # Drag state
 115        self._drag_type = None   # "point", "handle", or "scale_handle"
 116        self._drag_idx = None
 117        self._drag_side = None   # "in" or "out" (spline handles)
 118        self._drag_undo_pushed = False
 119
 120        # Options
 121        self._symmetric = False
 122        self._monotonic = True
 123
 124        # Virtual Analog state
 125        self._va_press_duration = 1.5
 126        self._va_total_duration = 3.0
 127
 128        # VA live simulation state
 129        self._va_sim_active = False
 130        self._va_sim_pressed = False
 131        self._va_sim_position = 0.0
 132        self._va_sim_velocity = 0.0
 133        self._va_sim_time = 0.0
 134        self._va_sim_trail = []
 135        self._va_sim_after_id = None
 136        self._va_sim_was_pressed = False
 137        self._va_sim_release_time = None
 138
 139        # Undo stack (max 30)
 140        self._undo_stack = UndoStack()
 141
 142        # Canvas sizing (computed on configure)
 143        self._margin_x = 35
 144        self._margin_y = 35
 145        self._plot_w = 0
 146        self._plot_h = 0
 147
 148        # X-axis range: -1..1 for sticks, 0..1 for triggers, 0..N for VA
 149        self._x_min = -1.0
 150        self._x_max = 1.0
 151
 152        # Y-axis range: defaults to (-1, 1), auto-scaled for visualization
 153        self._y_min = -1.0
 154        self._y_max = 1.0
 155
 156        self._build_ui()
 157
 158    # ------------------------------------------------------------------
 159    # UI Construction
 160    # ------------------------------------------------------------------
 161
 162    def _build_ui(self):
 163        # Toolbar row 1: checkboxes
 164        self._toolbar = ttk.Frame(self)
 165        self._toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 166
 167        self._sym_var = tk.BooleanVar()
 168        self._sym_cb = ttk.Checkbutton(
 169            self._toolbar, text="Symmetry",
 170            variable=self._sym_var, command=self._on_symmetry_toggle)
 171        self._sym_cb.pack(side=tk.LEFT, padx=3)
 172
 173        self._mono_var = tk.BooleanVar(value=True)
 174        self._mono_cb = ttk.Checkbutton(
 175            self._toolbar, text="Monotonic",
 176            variable=self._mono_var, command=self._on_monotonic_toggle)
 177        self._mono_cb.pack(side=tk.LEFT, padx=3)
 178
 179        self._proc_var = tk.BooleanVar(value=True)
 180        self._proc_cb = ttk.Checkbutton(
 181            self._toolbar, text="Show Processed",
 182            variable=self._proc_var, command=self._on_processed_toggle)
 183        # Packed dynamically in _update_toolbar after the checkboxes
 184
 185        # Toolbar row 2: action buttons
 186        self._toolbar2 = ttk.Frame(self)
 187
 188        self._undo_btn = ttk.Button(
 189            self._toolbar2, text="Undo", command=self._pop_undo, width=5)
 190        self._undo_btn.pack(side=tk.LEFT, padx=3)
 191
 192        self._reset_btn = ttk.Button(
 193            self._toolbar2, text="Reset", command=self._on_reset, width=5)
 194        self._reset_btn.pack(side=tk.LEFT, padx=3)
 195
 196        self._export_btn = ttk.Button(
 197            self._toolbar2, text="Export", command=self._on_export, width=6)
 198        self._export_btn.pack(side=tk.LEFT, padx=3)
 199
 200        self._import_btn = ttk.Button(
 201            self._toolbar2, text="Import", command=self._on_import, width=6)
 202        self._import_btn.pack(side=tk.LEFT, padx=3)
 203
 204        self._copy_btn = ttk.Button(
 205            self._toolbar2, text="Copy from...",
 206            command=self._on_copy_from, width=10)
 207        self._copy_btn.pack(side=tk.LEFT, padx=3)
 208
 209        # Visualization toolbar (shown for scaled/squared modes)
 210        self._vis_toolbar = ttk.Frame(self)
 211        self._wide_range_var = tk.BooleanVar(value=False)
 212        self._wide_range_cb = ttk.Checkbutton(
 213            self._vis_toolbar, text="Wide range",
 214            variable=self._wide_range_var,
 215            command=self._on_wide_range_toggle)
 216        self._wide_range_cb.pack(side=tk.LEFT, padx=3)
 217
 218        # VA simulate button (hold to simulate press, release to simulate release)
 219        self._va_sim_btn = ttk.Button(
 220            self._vis_toolbar, text="Hold to Simulate")
 221        self._va_sim_btn.bind("<ButtonPress-1>", self._on_va_sim_press)
 222        self._va_sim_btn.bind("<ButtonRelease-1>", self._on_va_sim_release)
 223        self._va_reset_btn = ttk.Button(
 224            self._vis_toolbar, text="Reset",
 225            command=self._on_va_sim_reset)
 226
 227        # Canvas (fills remaining space)
 228        self._canvas = tk.Canvas(self, bg=BG_INACTIVE,
 229                                 highlightthickness=0)
 230        self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 231
 232        self._canvas.bind("<Configure>", self._on_canvas_configure)
 233        self._canvas.bind("<ButtonPress-1>", self._on_press)
 234        self._canvas.bind("<B1-Motion>", self._on_drag)
 235        self._canvas.bind("<ButtonRelease-1>", self._on_release)
 236        self._canvas.bind("<Button-3>", self._on_right_click)
 237        self._canvas.bind("<Motion>", self._on_mouse_move)
 238        self._canvas.bind("<Leave>", self._on_mouse_leave)
 239
 240        # Status bar (bottom)
 241        self._status_var = tk.StringVar(value="No action selected")
 242        ttk.Label(self, textvariable=self._status_var,
 243                  relief=tk.SUNKEN, anchor=tk.W,
 244                  font=("TkDefaultFont", 7)).pack(
 245                      fill=tk.X, padx=2, pady=(0, 2))
 246
 247        self.bind("<Control-z>", self._on_ctrl_z)
 248
 249        # Start with toolbars hidden
 250        self._toolbar.pack_forget()
 251        self._toolbar2.pack_forget()
 252
 253    # ------------------------------------------------------------------
 254    # Dynamic Canvas Sizing
 255    # ------------------------------------------------------------------
 256
 257    def _on_canvas_configure(self, event):
 258        w, h = event.width, event.height
 259        if w < 20 or h < 20:
 260            return
 261
 262        margin_x = max(25, min(50, int(w * 0.08)))
 263        margin_y = max(25, min(50, int(h * 0.08)))
 264
 265        available_w = w - 2 * margin_x
 266        available_h = h - 2 * margin_y
 267
 268        # Square plot area
 269        plot_size = max(10, min(available_w, available_h))
 270
 271        self._margin_x = margin_x + (available_w - plot_size) // 2
 272        self._margin_y = margin_y + (available_h - plot_size) // 2
 273        self._plot_w = plot_size
 274        self._plot_h = plot_size
 275
 276        self._draw()
 277
 278    # ------------------------------------------------------------------
 279    # Coordinate Conversion
 280    # ------------------------------------------------------------------
 281
 282    def _d2c(self, x: float, y: float) -> tuple[float, float]:
 283        """Data coords to canvas pixels. X uses _x_min.._x_max, Y uses _y_min/_y_max."""
 284        x_range = self._x_max - self._x_min
 285        if x_range == 0:
 286            x_range = 2.0
 287        cx = self._margin_x + (x - self._x_min) / x_range * self._plot_w
 288        y_range = self._y_max - self._y_min
 289        if y_range == 0:
 290            y_range = 2.0
 291        cy = self._margin_y + (self._y_max - y) / y_range * self._plot_h
 292        return cx, cy
 293
 294    def _c2d(self, cx: float, cy: float) -> tuple[float, float]:
 295        """Canvas pixels to data coords."""
 296        if self._plot_w == 0 or self._plot_h == 0:
 297            return 0.0, 0.0
 298        x_range = self._x_max - self._x_min
 299        if x_range == 0:
 300            x_range = 2.0
 301        x = (cx - self._margin_x) / self._plot_w * x_range + self._x_min
 302        y_range = self._y_max - self._y_min
 303        if y_range == 0:
 304            y_range = 2.0
 305        y = self._y_max - (cy - self._margin_y) / self._plot_h * y_range
 306        return x, y
 307
 308    @property
 309    def _display_scale(self) -> float:
 310        """Scale factor applied to displayed Y values in editable modes.
 311
 312        Includes inversion as a sign flip so the curve visually reflects
 313        the runtime shaping pipeline.  This is exact for odd-symmetric
 314        curves (the common case) and a close approximation otherwise.
 315
 316        Controlled by the "Show Processed" checkbox — when unchecked,
 317        returns 1.0 (raw view).
 318        """
 319        if (self._action and self._mode in ("spline", "segment")
 320                and self._proc_var.get()):
 321            s = self._action.scale
 322            if self._action.inversion:
 323                s = -s
 324            return s
 325        return 1.0
 326
 327    @property
 328    def _handle_length(self) -> float:
 329        return max(20, self._plot_w * 0.1)
 330
 331    def _tangent_offset(self, tangent: float) -> tuple[float, float]:
 332        """Tangent slope to canvas-pixel offset for handle drawing.
 333
 334        Accounts for display scale and dynamic Y range so handles
 335        visually match the scaled curve direction.
 336        """
 337        # Pixels per data unit in each axis
 338        x_range = 1.0 - self._x_min
 339        if x_range == 0:
 340            x_range = 2.0
 341        ppx = self._plot_w / x_range
 342        y_range = self._y_max - self._y_min
 343        if y_range == 0:
 344            y_range = 2.0
 345        ppy = self._plot_h / y_range
 346        if ppx < 1 or ppy < 1:
 347            return self._handle_length, 0.0
 348        # Tangent is raw dy/dx; scale it for display
 349        vis_tangent = tangent * self._display_scale
 350        dx = 1.0 * ppx
 351        dy = -vis_tangent * ppy
 352        length = math.hypot(dx, dy)
 353        if length < 1e-6:
 354            return self._handle_length, 0.0
 355        s = self._handle_length / length
 356        return dx * s, dy * s
 357
 358    def _offset_to_tangent(self, dx: float, dy: float) -> float:
 359        """Canvas-pixel offset back to raw tangent slope (un-scaled)."""
 360        x_range = 1.0 - self._x_min
 361        if x_range == 0:
 362            x_range = 2.0
 363        ppx = self._plot_w / x_range
 364        y_range = self._y_max - self._y_min
 365        if y_range == 0:
 366            y_range = 2.0
 367        ppy = self._plot_h / y_range
 368        if ppx < 1 or ppy < 1:
 369            return 1.0
 370        data_dx = dx / ppx
 371        data_dy = -dy / ppy
 372        if abs(data_dx) < 1e-6:
 373            return 10.0 if data_dy > 0 else -10.0
 374        vis_tangent = data_dy / data_dx
 375        # Un-scale to get raw tangent
 376        s = self._display_scale
 377        if abs(s) < 1e-6:
 378            return vis_tangent
 379        return vis_tangent / s
 380
 381    # ------------------------------------------------------------------
 382    # Public API
 383    # ------------------------------------------------------------------
 384
 385    # Input names that only produce 0..1 (Xbox triggers)
 386    _TRIGGER_INPUTS = TRIGGER_INPUTS
 387
 388    def on_advanced_changed(self):
 389        """Refresh UI elements affected by Advanced menu toggles."""
 390        if self._mode in ("spline", "segment"):
 391            self._update_toolbar()
 392
 393    def load_action(self, action: ActionDefinition, qname: str,
 394                    bound_inputs: list[str] | None = None):
 395        """Populate the widget from the given action.
 396
 397        Args:
 398            bound_inputs: list of input names bound to this action.
 399                If all are trigger inputs (0..1 range), the X axis
 400                adjusts from -1..1 to 0..1.
 401        """
 402        self._va_sim_stop()
 403        self._action = action
 404        self._qname = qname
 405        self._undo_stack.clear()
 406        self._drag_type = None
 407        self._symmetric = False
 408        self._sym_var.set(False)
 409        self._monotonic = True
 410        self._mono_var.set(True)
 411
 412        # Determine mode from input_type + trigger_mode
 413        if action.input_type == InputType.VIRTUAL_ANALOG:
 414            self._mode = "virtual_analog"
 415        elif action.input_type == InputType.BOOLEAN_TRIGGER:
 416            self._mode = "threshold"
 417        elif action.input_type != InputType.ANALOG:
 418            self._mode = None
 419        elif action.trigger_mode == EventTriggerMode.SPLINE:
 420            self._mode = "spline"
 421        elif action.trigger_mode == EventTriggerMode.SEGMENTED:
 422            self._mode = "segment"
 423        elif action.trigger_mode == EventTriggerMode.RAW:
 424            self._mode = "raw"
 425        elif action.trigger_mode == EventTriggerMode.SCALED:
 426            self._mode = "scaled"
 427        elif action.trigger_mode == EventTriggerMode.SQUARED:
 428            self._mode = "squared"
 429        else:
 430            self._mode = None
 431
 432        # Set X range (must be after mode is determined)
 433        self._update_x_range(bound_inputs)
 434
 435        # Load points for editable modes
 436        if self._mode == "spline":
 437            pts = action.extra.get(EXTRA_SPLINE_POINTS)
 438            if not pts:
 439                pts = default_spline_points()
 440                action.extra[EXTRA_SPLINE_POINTS] = pts
 441            self._points = [dict(p) for p in pts]
 442            self._points.sort(key=lambda p: p["x"])
 443        elif self._mode == "segment":
 444            pts = action.extra.get(EXTRA_SEGMENT_POINTS)
 445            if not pts:
 446                pts = default_segment_points()
 447                action.extra[EXTRA_SEGMENT_POINTS] = pts
 448            self._points = [{"x": p["x"], "y": p["y"]} for p in pts]
 449            self._points.sort(key=lambda p: p["x"])
 450        else:
 451            self._points = []
 452
 453        self._update_toolbar()
 454        self._update_canvas_bg()
 455        self._draw()
 456
 457    def clear(self):
 458        """Clear to inactive state."""
 459        self._va_sim_stop()
 460        self._action = None
 461        self._qname = None
 462        self._mode = None
 463        self._points = []
 464        self._undo_stack.clear()
 465        self._drag_type = None
 466        self._update_toolbar()
 467        self._update_canvas_bg()
 468        self._draw()
 469
 470    def get_mode(self) -> str | None:
 471        return self._mode
 472
 473    def refresh(self):
 474        """Redraw from current action (call after external parameter change)."""
 475        if self._action:
 476            # Re-determine mode in case trigger_mode changed
 477            old_mode = self._mode
 478            self.load_action(self._action, self._qname)
 479            # Don't reset undo if mode didn't change
 480            if old_mode == self._mode:
 481                pass  # undo already cleared by load_action; acceptable
 482        else:
 483            self._draw()
 484
 485    def update_bindings(self, bound_inputs: list[str] | None = None):
 486        """Update X range when bindings change (assign/unassign)."""
 487        old_x_min = self._x_min
 488        self._update_x_range(bound_inputs)
 489        if self._x_min != old_x_min:
 490            self._draw()
 491
 492    def _update_x_range(self, bound_inputs: list[str] | None):
 493        """Set X range based on bound input types.
 494
 495        If ALL bound inputs are triggers (0..1), use 0..1.
 496        Otherwise use -1..1 (sticks, or no bindings).
 497        VA mode uses 0..total_duration (time axis).
 498        None means 'keep current' (e.g. refresh without rebinding).
 499        """
 500        if self._mode == "virtual_analog":
 501            self._x_min = 0.0
 502            self._x_max = self._va_total_duration
 503            return
 504        self._x_max = 1.0
 505        if bound_inputs is None:
 506            return
 507        if bound_inputs and all(
 508                inp in self._TRIGGER_INPUTS for inp in bound_inputs):
 509            self._x_min = 0.0
 510        else:
 511            self._x_min = -1.0
 512
 513    # ------------------------------------------------------------------
 514    # Toolbar Management
 515    # ------------------------------------------------------------------
 516
 517    def _update_toolbar(self):
 518        """Show/hide toolbar rows and mode-specific controls."""
 519        is_editable = self._mode in ("spline", "segment")
 520        is_vis_draggable = self._mode in ("scaled", "squared")
 521        is_va = self._mode == "virtual_analog"
 522        if is_editable:
 523            self._vis_toolbar.pack_forget()
 524            self._toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 525            self._toolbar2.pack(fill=tk.X, padx=2, pady=(0, 0))
 526            # Repack canvas after toolbars
 527            self._canvas.pack_forget()
 528            self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 529            # Show/hide monotonic and position Show Processed
 530            if self._mode == "segment":
 531                self._mono_cb.pack(side=tk.LEFT, padx=3,
 532                                   after=self._sym_cb)
 533                flags = self._get_advanced_flags()
 534                if not flags["nonmono"]:
 535                    self._mono_var.set(True)
 536                    self._mono_cb.config(state="disabled")
 537                else:
 538                    self._mono_cb.config(state="normal")
 539                self._proc_cb.pack(side=tk.LEFT, padx=3,
 540                                   after=self._mono_cb)
 541            else:
 542                self._mono_cb.pack_forget()
 543                self._proc_cb.pack(side=tk.LEFT, padx=3,
 544                                   after=self._sym_cb)
 545        elif is_vis_draggable or is_va:
 546            self._toolbar.pack_forget()
 547            self._toolbar2.pack_forget()
 548            self._proc_cb.pack_forget()
 549            self._vis_toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 550            # Show/hide VA sim buttons
 551            if is_va:
 552                self._va_sim_btn.pack(side=tk.LEFT, padx=3)
 553                self._va_reset_btn.pack(side=tk.LEFT, padx=3)
 554            else:
 555                self._va_sim_btn.pack_forget()
 556                self._va_reset_btn.pack_forget()
 557            # Repack canvas after vis toolbar
 558            self._canvas.pack_forget()
 559            self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 560        else:
 561            self._toolbar.pack_forget()
 562            self._toolbar2.pack_forget()
 563            self._proc_cb.pack_forget()
 564            self._va_sim_btn.pack_forget()
 565            self._va_reset_btn.pack_forget()
 566            self._vis_toolbar.pack_forget()
 567
 568    def _update_canvas_bg(self):
 569        bg = BG_INACTIVE if self._mode is None else BG_WHITE
 570        self._canvas.configure(bg=bg)
 571        if self._mode in ("spline", "segment"):
 572            cursor = "crosshair"
 573        elif self._mode == "threshold":
 574            cursor = "sb_h_double_arrow"
 575        elif self._mode == "virtual_analog":
 576            cursor = "sb_v_double_arrow"
 577        else:
 578            cursor = ""
 579        self._canvas.configure(cursor=cursor)
 580        if self._mode is None:
 581            self._status_var.set("No action selected")
 582        elif self._mode == "raw":
 583            self._status_var.set("Read-only: raw input (no shaping)")
 584        elif self._mode in ("scaled", "squared"):
 585            self._status_var.set("Drag handle to adjust scale")
 586        elif self._mode == "spline":
 587            self._status_var.set(
 588                "Click to add | Right-click to remove | Drag to adjust")
 589        elif self._mode == "segment":
 590            self._status_var.set(
 591                "Click to add | Right-click to remove | Drag to adjust")
 592        elif self._mode == "threshold":
 593            self._status_var.set("Drag handle to adjust threshold")
 594        elif self._mode == "virtual_analog":
 595            self._status_var.set(
 596                "Drag handles to adjust target/rest values")
 597
 598    # ------------------------------------------------------------------
 599    # Drawing
 600    # ------------------------------------------------------------------
 601
 602    def _compute_y_range(self):
 603        """Compute Y-axis range, auto-scaling when curve extends beyond -1..1."""
 604        if (self._mode in ("raw", "scaled", "squared")
 605                and self._action and self._wide_range_var.get()):
 606            y_min = 0.0
 607            y_max = 0.0
 608            x_span = 1.0 - self._x_min
 609            for i in range(_VIS_SAMPLES + 1):
 610                x = self._x_min + x_span * i / _VIS_SAMPLES
 611                y = self._compute_shaped_value(x)
 612                y_min = min(y_min, y)
 613                y_max = max(y_max, y)
 614            # Ensure range includes at least -1..1
 615            y_min = min(y_min, -1.0)
 616            y_max = max(y_max, 1.0)
 617            pad = (y_max - y_min) * 0.05
 618            self._y_min = y_min - pad
 619            self._y_max = y_max + pad
 620        elif self._mode in ("spline", "segment") and self._action:
 621            s = abs(self._display_scale)
 622            # Find actual curve extent (spline can overshoot control points)
 623            if s > 1e-6 and self._points and len(self._points) >= 2:
 624                y_min = 0.0
 625                y_max = 0.0
 626                for pt in self._points:
 627                    y_min = min(y_min, pt["y"] * self._display_scale)
 628                    y_max = max(y_max, pt["y"] * self._display_scale)
 629                if self._mode == "spline":
 630                    pts = self._points
 631                    n = 20 * (len(pts) - 1)
 632                    x_lo, x_hi = pts[0]["x"], pts[-1]["x"]
 633                    for i in range(n + 1):
 634                        x = x_lo + (x_hi - x_lo) * i / n
 635                        y = evaluate_spline(pts, x) * self._display_scale
 636                        y_min = min(y_min, y)
 637                        y_max = max(y_max, y)
 638                y_min = min(y_min, -1.0)
 639                y_max = max(y_max, 1.0)
 640                pad = (y_max - y_min) * 0.05
 641                self._y_min = y_min - pad
 642                self._y_max = y_max + pad
 643            else:
 644                self._y_min = -1.0
 645                self._y_max = 1.0
 646        elif self._mode == "threshold":
 647            self._y_min = -0.1
 648            self._y_max = 1.1
 649        elif self._mode == "virtual_analog" and self._action:
 650            target = float(self._action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
 651            rest = float(self._action.extra.get(EXTRA_VA_REST_VALUE, 0.0))
 652            lo = min(target, rest)
 653            hi = max(target, rest)
 654            pad = max((hi - lo) * 0.15, 0.1)
 655            self._y_min = lo - pad
 656            self._y_max = hi + pad
 657        else:
 658            self._y_min = -1.0
 659            self._y_max = 1.0
 660
 661    def _draw(self):
 662        c = self._canvas
 663        c.delete("all")
 664
 665        if self._plot_w < 10 or self._plot_h < 10:
 666            return
 667
 668        if self._mode is None:
 669            self._draw_inactive()
 670            return
 671
 672        self._compute_y_range()
 673        self._draw_grid()
 674
 675        if self._mode == "virtual_analog":
 676            if self._va_sim_active or self._va_sim_trail:
 677                self._draw_va_sim_trail()
 678            else:
 679                self._draw_va_ramp()
 680            self._draw_va_handles()
 681        elif self._mode in ("raw", "scaled", "squared"):
 682            self._draw_deadband_band()
 683            self._draw_computed_curve()
 684            if self._mode in ("scaled", "squared"):
 685                self._draw_scale_handle()
 686        elif self._mode == "threshold":
 687            self._draw_threshold_curve()
 688            self._draw_threshold_handle()
 689        elif self._mode == "spline":
 690            self._draw_spline_curve()
 691            self._draw_handles()
 692            self._draw_points()
 693        elif self._mode == "segment":
 694            self._draw_segment_curve()
 695            self._draw_points()
 696
 697    def _draw_inactive(self):
 698        c = self._canvas
 699        cw = int(c.cget("width")) if c.winfo_width() < 2 else c.winfo_width()
 700        ch = int(c.cget("height")) if c.winfo_height() < 2 else c.winfo_height()
 701        c.create_text(cw // 2, ch // 2,
 702                      text="Select an analog or virtual analog\naction to view curve",
 703                      fill="#999999", font=("TkDefaultFont", 10))
 704
 705    def _nice_grid_step(self, span: float) -> float:
 706        """Choose a nice gridline step for the given data span."""
 707        return nice_grid_step(span)
 708
 709    def _draw_grid(self):
 710        c = self._canvas
 711        small = self._plot_w < 200
 712
 713        # X gridlines (dynamic based on x range)
 714        if self._mode == "virtual_analog":
 715            x_step = self._nice_grid_step(self._x_max - self._x_min)
 716            x_grid = []
 717            v = 0.0
 718            while v <= self._x_max + x_step * 0.01:
 719                x_grid.append(v)
 720                v += x_step
 721        elif self._x_min >= 0:
 722            x_grid = [i / 4 for i in range(0, 5)]  # 0, 0.25, 0.5, 0.75, 1
 723        else:
 724            x_grid = [i / 4 for i in range(-4, 5)]  # -1..1 by 0.25
 725        for v in x_grid:
 726            cx, _ = self._d2c(v, 0)
 727            is_axis = abs(v) < 0.01
 728            is_major = abs(v * 2) % 1 < 0.01
 729            if small and not is_axis and not is_major:
 730                continue
 731            color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 732            w = 2 if is_axis else 1
 733            c.create_line(cx, self._margin_y,
 734                          cx, self._margin_y + self._plot_h,
 735                          fill=color, width=w)
 736
 737        # Y gridlines (dynamic range)
 738        y_step = self._nice_grid_step(self._y_max - self._y_min)
 739        # Start from a rounded value below y_min
 740        y_start = math.floor(self._y_min / y_step) * y_step
 741        v = y_start
 742        while v <= self._y_max + y_step * 0.01:
 743            _, cy = self._d2c(0, v)
 744            is_axis = abs(v) < y_step * 0.01
 745            color = GRID_AXIS if is_axis else GRID_MAJOR
 746            w = 2 if is_axis else 1
 747            c.create_line(self._margin_x, cy,
 748                          self._margin_x + self._plot_w, cy,
 749                          fill=color, width=w)
 750            v += y_step
 751
 752        # Labels
 753        if self._plot_w >= 100:
 754            font_size = 7 if self._plot_w < 250 else 8
 755            # X labels
 756            if self._mode == "virtual_analog":
 757                x_labels = x_grid
 758            elif self._x_min >= 0:
 759                x_labels = [0.0, 0.25, 0.5, 0.75, 1.0]
 760            else:
 761                x_labels = [-1.0, -0.5, 0.0, 0.5, 1.0]
 762            for v in x_labels:
 763                cx, _ = self._d2c(v, 0)
 764                label = f"{v:g}s" if self._mode == "virtual_analog" else f"{v:g}"
 765                c.create_text(cx, self._margin_y + self._plot_h + 12,
 766                              text=label, fill=LABEL_COLOR,
 767                              font=("TkDefaultFont", font_size))
 768            # Y labels
 769            v = y_start
 770            while v <= self._y_max + y_step * 0.01:
 771                _, cy = self._d2c(0, v)
 772                c.create_text(self._margin_x - 18, cy,
 773                              text=f"{v:g}", fill=LABEL_COLOR,
 774                              font=("TkDefaultFont", font_size))
 775                v += y_step
 776
 777        # Reference lines at ±1 when Y range extends beyond -1..1
 778        if self._y_min < -1.05 or self._y_max > 1.05:
 779            for ref_y in (-1.0, 1.0):
 780                _, ry = self._d2c(0, ref_y)
 781                c.create_line(self._margin_x, ry,
 782                              self._margin_x + self._plot_w, ry,
 783                              fill="#b0b0ff", width=1, dash=(6, 3))
 784
 785        # Border
 786        c.create_rectangle(self._margin_x, self._margin_y,
 787                           self._margin_x + self._plot_w,
 788                           self._margin_y + self._plot_h,
 789                           outline="#808080")
 790
 791    # --- Visualization Modes ---
 792
 793    def _compute_shaped_value(self, x: float) -> float:
 794        """Compute the shaped output for visualization modes."""
 795        if not self._action:
 796            return x
 797
 798        # RAW bypasses all shaping
 799        if self._mode == "raw":
 800            return x
 801
 802        val = x
 803
 804        # 1. Inversion
 805        if self._action.inversion:
 806            val = -val
 807
 808        # 2. Deadband
 809        db = self._action.deadband
 810        if db > 0 and abs(val) < db:
 811            val = 0.0
 812        elif db > 0:
 813            sign = 1.0 if val >= 0 else -1.0
 814            val = sign * (abs(val) - db) / (1.0 - db) if db < 1.0 else 0.0
 815
 816        # 3. Curve function
 817        if self._mode == "squared":
 818            sign = 1.0 if val >= 0 else -1.0
 819            val = sign * val * val
 820
 821        # 4. Scale
 822        val = val * self._action.scale
 823
 824        return val
 825
 826    def _draw_computed_curve(self):
 827        """Draw the visualization curve for raw/scaled/squared modes."""
 828        c = self._canvas
 829        coords = []
 830        x_span = 1.0 - self._x_min
 831        for i in range(_VIS_SAMPLES + 1):
 832            x = self._x_min + x_span * i / _VIS_SAMPLES
 833            y = self._compute_shaped_value(x)
 834            cx, cy = self._d2c(x, y)
 835            coords.extend([cx, cy])
 836        if len(coords) >= 4:
 837            c.create_line(*coords, fill=CURVE_LINE, width=2, smooth=False)
 838
 839    def _draw_deadband_band(self):
 840        """Draw shaded deadband region."""
 841        if not self._action or self._action.deadband <= 0:
 842            return
 843        db = self._action.deadband
 844        x0, y0 = self._d2c(-db, self._y_max)
 845        x1, y1 = self._d2c(db, self._y_min)
 846        self._canvas.create_rectangle(
 847            x0, y0, x1, y1,
 848            fill=_DEADBAND_FILL, outline="", stipple="gray25")
 849
 850    def _draw_scale_handle(self):
 851        """Draw draggable scale handle at (1.0, f(1.0))."""
 852        if not self._action:
 853            return
 854        y_val = self._compute_shaped_value(1.0)
 855        cx, cy = self._d2c(1.0, y_val)
 856        r = _POINT_RADIUS + 1
 857        # Diamond shape
 858        self._canvas.create_polygon(
 859            cx, cy - r, cx + r, cy, cx, cy + r, cx - r, cy,
 860            fill=_SCALE_HANDLE, outline=_SCALE_HANDLE_OUTLINE, width=2)
 861
 862    # --- Threshold Mode ---
 863
 864    def _draw_threshold_curve(self):
 865        """Draw a step function: 0 below threshold, 1 at/above threshold."""
 866        if not self._action:
 867            return
 868        c = self._canvas
 869        t = self._action.threshold
 870
 871        # Horizontal line at y=0 from x_min to threshold
 872        x0c, y0c = self._d2c(self._x_min, 0.0)
 873        xtc_lo, ytc_lo = self._d2c(t, 0.0)
 874        c.create_line(x0c, y0c, xtc_lo, ytc_lo,
 875                      fill=CURVE_LINE, width=2)
 876
 877        # Vertical step at threshold
 878        xtc_hi, ytc_hi = self._d2c(t, 1.0)
 879        c.create_line(xtc_lo, ytc_lo, xtc_hi, ytc_hi,
 880                      fill=CURVE_LINE, width=2)
 881
 882        # Horizontal line at y=1 from threshold to x_max=1.0
 883        x1c, y1c = self._d2c(1.0, 1.0)
 884        c.create_line(xtc_hi, ytc_hi, x1c, y1c,
 885                      fill=CURVE_LINE, width=2)
 886
 887    def _draw_threshold_handle(self):
 888        """Draw a draggable vertical line and diamond handle at threshold."""
 889        if not self._action:
 890            return
 891        c = self._canvas
 892        t = self._action.threshold
 893
 894        # Vertical dashed guide line spanning the plot
 895        xtc, ytop = self._d2c(t, self._y_max)
 896        _, ybot = self._d2c(t, self._y_min)
 897        c.create_line(xtc, ytop, xtc, ybot,
 898                      fill=_THRESHOLD_LINE, width=1, dash=(6, 3))
 899
 900        # Diamond handle at (threshold, 0.5) — midpoint of the step
 901        _, yh = self._d2c(t, 0.5)
 902        r = _POINT_RADIUS + 1
 903        c.create_polygon(
 904            xtc, yh - r, xtc + r, yh, xtc, yh + r, xtc - r, yh,
 905            fill=_THRESHOLD_HANDLE, outline=_THRESHOLD_HANDLE_OUTLINE,
 906            width=2)
 907
 908    # --- Virtual Analog Mode ---
 909
 910    _VA_PRESS_COLOR = "#2060c0"    # Blue for press phase
 911    _VA_RELEASE_COLOR = "#c04020"  # Red-orange for release phase
 912    _VA_HANDLE_COLOR = "#40a040"   # Green for target/rest handles
 913    _VA_HANDLE_OUTLINE = "#207020"
 914    _VA_DIVIDER_COLOR = "#808080"
 915
 916    def _draw_va_ramp(self):
 917        """Draw the VA ramp simulation curve."""
 918        if not self._action:
 919            return
 920        from utils.input.virtual_analog import simulate_va_ramp
 921
 922        extra = self._action.extra
 923        neg_ramp = extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
 924        neg_accel = extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
 925        points = simulate_va_ramp(
 926            ramp_rate=float(extra.get(EXTRA_VA_RAMP_RATE, 0.0)),
 927            acceleration=float(extra.get(EXTRA_VA_ACCELERATION, 0.0)),
 928            negative_ramp_rate=float(neg_ramp) if neg_ramp is not None
 929            else None,
 930            negative_acceleration=float(neg_accel) if neg_accel is not None
 931            else None,
 932            zero_vel_on_release=bool(extra.get(
 933                EXTRA_VA_ZERO_VEL_ON_RELEASE, False)),
 934            target_value=float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0)),
 935            rest_value=float(extra.get(EXTRA_VA_REST_VALUE, 0.0)),
 936            total_duration=self._va_total_duration,
 937            press_duration=self._va_press_duration,
 938        )
 939
 940        c = self._canvas
 941        press_dur = self._va_press_duration
 942
 943        # Draw press/release divider
 944        dx, dy_top = self._d2c(press_dur, self._y_max)
 945        _, dy_bot = self._d2c(press_dur, self._y_min)
 946        c.create_line(dx, dy_top, dx, dy_bot,
 947                      fill=self._VA_DIVIDER_COLOR, width=1, dash=(6, 3))
 948
 949        # Labels for release/press regions (release drawn first)
 950        font_size = 7 if self._plot_w < 250 else 8
 951        mid_release = press_dur / 2
 952        mid_press = (press_dur + self._va_total_duration) / 2
 953        rx, _ = self._d2c(mid_release, self._y_max)
 954        px, _ = self._d2c(mid_press, self._y_max)
 955        c.create_text(rx, self._margin_y - 8, text="Release",
 956                      fill=self._VA_RELEASE_COLOR,
 957                      font=("TkDefaultFont", font_size))
 958        c.create_text(px, self._margin_y - 8, text="Press",
 959                      fill=self._VA_PRESS_COLOR,
 960                      font=("TkDefaultFont", font_size))
 961
 962        # Draw curve in two colors (release first, then press)
 963        release_coords = []
 964        press_coords = []
 965        for t, pos in points:
 966            cx, cy = self._d2c(t, pos)
 967            if t <= press_dur:
 968                release_coords.extend([cx, cy])
 969            else:
 970                if not press_coords and release_coords:
 971                    # Bridge point: add last release point to press
 972                    press_coords.extend(release_coords[-2:])
 973                press_coords.extend([cx, cy])
 974
 975        if len(release_coords) >= 4:
 976            c.create_line(*release_coords, fill=self._VA_RELEASE_COLOR,
 977                          width=2, smooth=False)
 978        if len(press_coords) >= 4:
 979            c.create_line(*press_coords, fill=self._VA_PRESS_COLOR,
 980                          width=2, smooth=False)
 981
 982    def _draw_va_handles(self):
 983        """Draw draggable horizontal handles for target and rest values."""
 984        if not self._action:
 985            return
 986        c = self._canvas
 987        extra = self._action.extra
 988        target = float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
 989        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
 990
 991        # Target handle: horizontal dashed line + diamond
 992        _, ty = self._d2c(0, target)
 993        c.create_line(self._margin_x, ty,
 994                      self._margin_x + self._plot_w, ty,
 995                      fill=self._VA_HANDLE_COLOR, width=1, dash=(4, 4))
 996        # Diamond at right edge
 997        hx = self._margin_x + self._plot_w - 15
 998        r = _POINT_RADIUS
 999        c.create_polygon(
1000            hx, ty - r, hx + r, ty, hx, ty + r, hx - r, ty,
1001            fill=self._VA_HANDLE_COLOR, outline=self._VA_HANDLE_OUTLINE,
1002            width=2)
1003        c.create_text(hx - r - 4, ty - r - 2, text="target",
1004                      fill=self._VA_HANDLE_COLOR, anchor=tk.E,
1005                      font=("TkDefaultFont", 7))
1006
1007        # Rest handle: horizontal dashed line + diamond
1008        _, ry = self._d2c(0, rest)
1009        c.create_line(self._margin_x, ry,
1010                      self._margin_x + self._plot_w, ry,
1011                      fill=self._VA_RELEASE_COLOR, width=1, dash=(4, 4))
1012        hx2 = self._margin_x + 15
1013        c.create_polygon(
1014            hx2, ry - r, hx2 + r, ry, hx2, ry + r, hx2 - r, ry,
1015            fill=self._VA_RELEASE_COLOR, outline="#901010",
1016            width=2)
1017        c.create_text(hx2 + r + 4, ry - r - 2, text="rest",
1018                      fill=self._VA_RELEASE_COLOR, anchor=tk.W,
1019                      font=("TkDefaultFont", 7))
1020
1021    def _draw_va_sim_trail(self):
1022        """Draw the live simulation trail with a dot at current position."""
1023        if not self._va_sim_trail:
1024            return
1025        c = self._canvas
1026        release_t = self._va_sim_release_time
1027
1028        # Build press and release coordinate lists
1029        press_coords = []
1030        release_coords = []
1031        for t, pos in self._va_sim_trail:
1032            cx, cy = self._d2c(t, pos)
1033            if release_t is None or t <= release_t:
1034                press_coords.extend([cx, cy])
1035            else:
1036                if not release_coords and press_coords:
1037                    release_coords.extend(press_coords[-2:])
1038                release_coords.extend([cx, cy])
1039
1040        if len(press_coords) >= 4:
1041            c.create_line(*press_coords, fill=self._VA_PRESS_COLOR,
1042                          width=2, smooth=False)
1043        if len(release_coords) >= 4:
1044            c.create_line(*release_coords, fill=self._VA_RELEASE_COLOR,
1045                          width=2, smooth=False)
1046
1047        # Draw dot at current position
1048        last_t, last_pos = self._va_sim_trail[-1]
1049        dx, dy = self._d2c(last_t, last_pos)
1050        r = self._VA_DOT_RADIUS
1051        color = (self._VA_PRESS_COLOR if self._va_sim_pressed
1052                 else self._VA_RELEASE_COLOR)
1053        c.create_oval(dx - r, dy - r, dx + r, dy + r,
1054                      fill=color, outline="white", width=1)
1055
1056        # Status text
1057        if self._va_sim_active:
1058            phase = "Press" if self._va_sim_pressed else "Release"
1059            self._status_var.set(
1060                f"{phase}  t={last_t:.2f}s  value={last_pos:.3f}")
1061
1062    # --- Spline Mode ---
1063
1064    def _draw_spline_curve(self):
1065        pts = self._points
1066        if len(pts) < 2:
1067            return
1068        s = self._display_scale
1069        n = _CURVE_SAMPLES_PER_SEG * (len(pts) - 1)
1070        x_min, x_max = pts[0]["x"], pts[-1]["x"]
1071        coords = []
1072        for i in range(n + 1):
1073            x = x_min + (x_max - x_min) * i / n
1074            y = evaluate_spline(pts, x) * s
1075            cx, cy = self._d2c(x, y)
1076            coords.extend([cx, cy])
1077        if len(coords) >= 4:
1078            self._canvas.create_line(
1079                *coords, fill=CURVE_LINE, width=2, smooth=False)
1080
1081    def _draw_handles(self):
1082        c = self._canvas
1083        s = self._display_scale
1084        for i, pt in enumerate(self._points):
1085            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1086            hdx, hdy = self._tangent_offset(pt["tangent"])
1087            c.create_line(cx - hdx, cy - hdy, cx + hdx, cy + hdy,
1088                          fill=HANDLE_LINE, width=1, dash=(4, 4))
1089            if i > 0:
1090                hx, hy = cx - hdx, cy - hdy
1091                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
1092                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
1093                              fill=HANDLE_FILL, outline="#308030")
1094            if i < len(self._points) - 1:
1095                hx, hy = cx + hdx, cy + hdy
1096                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
1097                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
1098                              fill=HANDLE_FILL, outline="#308030")
1099
1100    # --- Segment Mode ---
1101
1102    def _draw_segment_curve(self):
1103        pts = self._points
1104        if len(pts) < 2:
1105            return
1106        s = self._display_scale
1107        coords = []
1108        for pt in pts:
1109            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1110            coords.extend([cx, cy])
1111        if len(coords) >= 4:
1112            self._canvas.create_line(
1113                *coords, fill=CURVE_LINE, width=2, smooth=False)
1114
1115    # --- Points (shared by editable modes) ---
1116
1117    def _draw_points(self):
1118        c = self._canvas
1119        s = self._display_scale
1120        for i, pt in enumerate(self._points):
1121            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1122            is_endpoint = (i == 0 or i == len(self._points) - 1)
1123            is_mirror = (self._symmetric
1124                         and pt["x"] < -_MIN_X_GAP / 2)
1125            if is_mirror:
1126                fill = MIRROR_LINE
1127            elif is_endpoint:
1128                fill = ENDPOINT_FILL
1129            else:
1130                fill = POINT_FILL
1131            c.create_oval(cx - _POINT_RADIUS, cy - _POINT_RADIUS,
1132                          cx + _POINT_RADIUS, cy + _POINT_RADIUS,
1133                          fill=fill, outline=POINT_OUTLINE, width=2)
1134
1135    # ------------------------------------------------------------------
1136    # Curve Tracker (follow mouse along curve)
1137    # ------------------------------------------------------------------
1138
1139    def _evaluate_display_y(self, x: float) -> float | None:
1140        """Return the displayed Y value on the curve at data-x, or None."""
1141        if self._mode in ("raw", "scaled", "squared"):
1142            return self._compute_shaped_value(x)
1143        elif self._mode == "spline" and len(self._points) >= 2:
1144            return evaluate_spline(self._points, x) * self._display_scale
1145        elif self._mode == "segment" and len(self._points) >= 2:
1146            return evaluate_segments(self._points, x) * self._display_scale
1147        return None
1148
1149    def _on_mouse_move(self, event):
1150        """Draw a tracking dot that follows the curve under the cursor."""
1151        self._canvas.delete(_TRACKER_TAG)
1152        if self._mode is None or self._drag_type is not None:
1153            return
1154        x, _ = self._c2d(event.x, event.y)
1155        if x < self._x_min or x > 1.0:
1156            return
1157        y = self._evaluate_display_y(x)
1158        if y is None:
1159            return
1160        cx, cy = self._d2c(x, y)
1161        r = _TRACKER_RADIUS
1162        self._canvas.create_oval(
1163            cx - r, cy - r, cx + r, cy + r,
1164            fill=_TRACKER_FILL, outline="", tags=_TRACKER_TAG)
1165        # Label with X,Y offset to upper-right; flip if near edge
1166        label = f"({x:.2f}, {y:.2f})"
1167        lx = cx + 10 if cx < self._margin_x + self._plot_w - 80 else cx - 10
1168        ly = cy - 14 if cy > self._margin_y + 14 else cy + 14
1169        anchor = tk.SW if lx > cx else tk.SE
1170        if ly > cy:
1171            anchor = tk.NW if lx > cx else tk.NE
1172        self._canvas.create_text(
1173            lx, ly, text=label, fill=_TRACKER_FILL,
1174            font=("TkDefaultFont", 7), anchor=anchor,
1175            tags=_TRACKER_TAG)
1176
1177    def _on_mouse_leave(self, event):
1178        """Remove tracker when mouse leaves the canvas."""
1179        self._canvas.delete(_TRACKER_TAG)
1180
1181    # ------------------------------------------------------------------
1182    # Hit Testing
1183    # ------------------------------------------------------------------
1184
1185    def _hit_test(self, cx, cy):
1186        """Find element at canvas position.
1187
1188        Returns (type, idx, side) or None.
1189        """
1190        s = self._display_scale
1191        if self._mode == "spline":
1192            # Check handles first
1193            for i, pt in enumerate(self._points):
1194                if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
1195                    continue
1196                px, py = self._d2c(pt["x"], pt["y"] * s)
1197                hdx, hdy = self._tangent_offset(pt["tangent"])
1198                if i < len(self._points) - 1:
1199                    if math.hypot(cx - (px + hdx),
1200                                  cy - (py + hdy)) <= _HANDLE_RADIUS + 3:
1201                        return ("handle", i, "out")
1202                if i > 0:
1203                    if math.hypot(cx - (px - hdx),
1204                                  cy - (py - hdy)) <= _HANDLE_RADIUS + 3:
1205                        return ("handle", i, "in")
1206
1207        if self._mode in ("spline", "segment"):
1208            for i, pt in enumerate(self._points):
1209                if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
1210                    continue
1211                px, py = self._d2c(pt["x"], pt["y"] * s)
1212                if math.hypot(cx - px, cy - py) <= _POINT_RADIUS + 3:
1213                    return ("point", i, None)
1214
1215        if self._mode in ("scaled", "squared"):
1216            # Check scale handle
1217            y_val = self._compute_shaped_value(1.0)
1218            hcx, hcy = self._d2c(1.0, y_val)
1219            if math.hypot(cx - hcx, cy - hcy) <= _POINT_RADIUS + 5:
1220                return ("scale_handle", 0, None)
1221
1222        if self._mode == "threshold" and self._action:
1223            # Check threshold handle at (threshold, 0.5)
1224            t = self._action.threshold
1225            hcx, hcy = self._d2c(t, 0.5)
1226            if math.hypot(cx - hcx, cy - hcy) <= _POINT_RADIUS + 5:
1227                return ("threshold_handle", 0, None)
1228
1229        if self._mode == "virtual_analog" and self._action:
1230            extra = self._action.extra
1231            target = extra.get(EXTRA_VA_TARGET_VALUE, 1.0)
1232            rest = extra.get(EXTRA_VA_REST_VALUE, 0.0)
1233            # Target handle (right side diamond)
1234            hx = self._margin_x + self._plot_w - 15
1235            _, ty = self._d2c(0, target)
1236            if math.hypot(cx - hx, cy - ty) <= _POINT_RADIUS + 5:
1237                return ("va_target_handle", 0, None)
1238            # Rest handle (left side diamond)
1239            hx2 = self._margin_x + 15
1240            _, ry = self._d2c(0, rest)
1241            if math.hypot(cx - hx2, cy - ry) <= _POINT_RADIUS + 5:
1242                return ("va_rest_handle", 0, None)
1243
1244        return None
1245
1246    # ------------------------------------------------------------------
1247    # Mouse Interaction
1248    # ------------------------------------------------------------------
1249
1250    def _on_press(self, event):
1251        if self._mode is None:
1252            return
1253
1254        hit = self._hit_test(event.x, event.y)
1255        if hit:
1256            self._drag_type, self._drag_idx, self._drag_side = hit
1257            self._drag_undo_pushed = False
1258        else:
1259            self._drag_type = None
1260            if self._mode in ("spline", "segment"):
1261                self._add_point_at(event.x, event.y)
1262
1263    def _on_drag(self, event):
1264        if self._drag_type is None:
1265            return
1266
1267        if self._drag_type == "scale_handle":
1268            self._drag_scale_handle(event)
1269            return
1270
1271        if self._drag_type == "threshold_handle":
1272            self._drag_threshold_handle(event)
1273            return
1274
1275        if self._drag_type in ("va_target_handle", "va_rest_handle"):
1276            self._drag_va_handle(event)
1277            return
1278
1279        if self._mode not in ("spline", "segment"):
1280            return
1281
1282        if not self._drag_undo_pushed:
1283            self._push_undo()
1284            self._drag_undo_pushed = True
1285
1286        i = self._drag_idx
1287        pt = self._points[i]
1288
1289        s = self._display_scale
1290
1291        if self._drag_type == "point":
1292            _, vis_y = self._c2d(event.x, event.y)
1293            # Un-scale cursor Y to get raw point Y
1294            y = vis_y / s if abs(s) > 1e-6 else vis_y
1295            is_endpoint = (i == 0 or i == len(self._points) - 1)
1296
1297            # Center point with symmetry: y locked to 0
1298            if self._symmetric and abs(pt["x"]) < _MIN_X_GAP / 2:
1299                pt["y"] = 0.0
1300            else:
1301                y = max(-1.0, min(1.0, y))
1302                if self._mode == "segment" and self._monotonic:
1303                    y = self._clamp_monotonic(i, y)
1304                pt["y"] = round(y, 3)
1305
1306            # Intermediate points: also move X
1307            if not is_endpoint:
1308                x, _ = self._c2d(event.x, event.y)
1309                x_lo = self._points[i - 1]["x"] + _MIN_X_GAP
1310                x_hi = self._points[i + 1]["x"] - _MIN_X_GAP
1311                if self._symmetric and pt["x"] > 0:
1312                    x_lo = max(x_lo, _MIN_X_GAP)
1313                pt["x"] = round(max(x_lo, min(x_hi, x)), 3)
1314
1315                if self._mode == "segment" and self._monotonic:
1316                    pt["y"] = round(
1317                        self._clamp_monotonic(i, pt["y"]), 3)
1318
1319        elif self._drag_type == "handle" and self._mode == "spline":
1320            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1321            dx, dy = event.x - cx, event.y - cy
1322            if self._drag_side == "in":
1323                dx, dy = -dx, -dy
1324            if math.hypot(dx, dy) > 5:
1325                t = self._offset_to_tangent(dx, dy)
1326                pt["tangent"] = round(max(-10.0, min(10.0, t)), 3)
1327
1328        if self._symmetric:
1329            self._enforce_symmetry()
1330            for j, p in enumerate(self._points):
1331                if p is pt:
1332                    self._drag_idx = j
1333                    break
1334
1335        info = f"x={pt['x']:.2f}  y={pt['y']:.3f}"
1336        if self._mode == "spline":
1337            info += f"  tangent={pt.get('tangent', 0):.3f}"
1338        self._status_var.set(f"Point {self._drag_idx}: {info}")
1339        self._draw()
1340
1341    def _drag_scale_handle(self, event):
1342        """Drag the scale handle to adjust action.scale.
1343
1344        Uses pixel-delta from drag start for consistent sensitivity
1345        regardless of current Y range or scale magnitude.
1346        """
1347        if not self._action:
1348            return
1349        if not self._drag_undo_pushed:
1350            if self._on_before_change:
1351                self._on_before_change(200)
1352            self._drag_undo_pushed = True
1353            self._drag_start_y = event.y
1354            self._drag_start_scale = self._action.scale
1355
1356        if self._plot_h <= 0:
1357            return
1358
1359        # Compute unscaled base output at x=1.0
1360        old_scale = self._action.scale
1361        self._action.scale = 1.0
1362        base = self._compute_shaped_value(1.0)
1363        self._action.scale = old_scale
1364
1365        # Pixel delta (positive = dragged up = increase if base > 0)
1366        delta_px = self._drag_start_y - event.y
1367        # Fixed rate: one full plot height = 2.0 scale units
1368        scale_per_px = 2.0 / self._plot_h
1369        # Match drag direction to curve direction
1370        direction = 1.0 if base >= 0 else -1.0
1371        new_scale = self._drag_start_scale + delta_px * scale_per_px * direction
1372
1373        # Clamp scale: tighter when wide range is off
1374        if self._wide_range_var.get():
1375            new_scale = max(-10.0, min(10.0, new_scale))
1376        else:
1377            # Clamp so max output stays in [-1, 1]
1378            if abs(base) > 0.01:
1379                max_scale = 1.0 / abs(base)
1380                new_scale = max(-max_scale, min(max_scale, new_scale))
1381            else:
1382                new_scale = max(-1.0, min(1.0, new_scale))
1383
1384        new_scale = round(new_scale, 2)
1385        self._action.scale = new_scale
1386        self._status_var.set(f"Scale: {new_scale:.2f}")
1387        self._draw()
1388
1389        if self._on_curve_changed:
1390            self._on_curve_changed()
1391
1392    def _drag_threshold_handle(self, event):
1393        """Drag the threshold handle to adjust action.threshold."""
1394        if not self._action:
1395            return
1396        if not self._drag_undo_pushed:
1397            if self._on_before_change:
1398                self._on_before_change(200)
1399            self._drag_undo_pushed = True
1400
1401        # Convert pixel X to data X, clamp to 0..1
1402        x, _ = self._c2d(event.x, event.y)
1403        new_threshold = round(max(0.0, min(1.0, x)), 2)
1404        self._action.threshold = new_threshold
1405        self._status_var.set(f"Threshold: {new_threshold:.2f}")
1406        self._draw()
1407
1408        if self._on_curve_changed:
1409            self._on_curve_changed()
1410
1411    def _drag_va_handle(self, event):
1412        """Drag VA target or rest handle to adjust values."""
1413        if not self._action:
1414            return
1415        if not self._drag_undo_pushed:
1416            if self._on_before_change:
1417                self._on_before_change(200)
1418            self._drag_undo_pushed = True
1419
1420        _, y = self._c2d(event.x, event.y)
1421        y = round(max(-10.0, min(10.0, y)), 2)
1422
1423        if self._drag_type == "va_target_handle":
1424            self._action.extra[EXTRA_VA_TARGET_VALUE] = y
1425            self._status_var.set(f"Target: {y:.2f}")
1426        else:
1427            self._action.extra[EXTRA_VA_REST_VALUE] = y
1428            self._status_var.set(f"Rest: {y:.2f}")
1429        self._draw()
1430
1431        if self._on_curve_changed:
1432            self._on_curve_changed()
1433
1434    # ------------------------------------------------------------------
1435    # VA Live Simulation
1436    # ------------------------------------------------------------------
1437
1438    _VA_SIM_DT = 0.02       # 50 Hz physics step
1439    _VA_SIM_INTERVAL = 20   # ms between animation frames
1440    _VA_SIM_TIMEOUT = 10.0  # Max sim duration (seconds)
1441    _VA_DOT_RADIUS = 5
1442
1443    def _on_va_sim_press(self, event):
1444        """Start or continue live simulation when button is pressed."""
1445        if not self._action or self._mode != "virtual_analog":
1446            return
1447        is_toggle = (self._action.extra.get(
1448            EXTRA_VA_BUTTON_MODE, "held") == "toggle")
1449        if is_toggle:
1450            self._va_sim_pressed = not self._va_sim_pressed
1451        else:
1452            self._va_sim_pressed = True
1453        if self._va_sim_active:
1454            # Sim already running — continue trail
1455            return
1456        if self._va_sim_trail:
1457            # Previous sim finished — continue from where it left off
1458            self._va_sim_active = True
1459            self._va_sim_tick()
1460            return
1461        # Fresh start
1462        extra = self._action.extra
1463        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
1464        self._va_sim_position = rest
1465        self._va_sim_velocity = 0.0
1466        self._va_sim_time = 0.0
1467        self._va_sim_trail = [(0.0, rest)]
1468        self._va_sim_active = True
1469        self._va_sim_was_pressed = False
1470        self._va_sim_release_time = None
1471        self._va_sim_tick()
1472
1473    def _on_va_sim_release(self, event):
1474        """Mark button released — sim continues for release phase.
1475
1476        In toggle mode, release is a no-op (state toggled on press).
1477        """
1478        if (self._action and self._action.extra.get(
1479                EXTRA_VA_BUTTON_MODE, "held") == "toggle"):
1480            return
1481        self._va_sim_pressed = False
1482
1483    def _va_sim_tick(self):
1484        """Run one physics step and schedule the next frame."""
1485        if not self._va_sim_active or not self._action:
1486            return
1487
1488        extra = self._action.extra
1489        dt = self._VA_SIM_DT
1490        pressed = self._va_sim_pressed
1491
1492        ramp_rate = float(extra.get(EXTRA_VA_RAMP_RATE, 0.0))
1493        acceleration = float(extra.get(EXTRA_VA_ACCELERATION, 0.0))
1494        neg_ramp = extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
1495        neg_ramp = float(neg_ramp) if neg_ramp is not None else ramp_rate
1496        neg_accel = extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
1497        neg_accel = float(neg_accel) if neg_accel is not None else acceleration
1498        zero_vel = bool(extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False))
1499        target = float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
1500        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
1501        min_val = min(rest, target)
1502        max_val = max(rest, target)
1503
1504        # Release edge detection
1505        if self._va_sim_was_pressed and not pressed:
1506            if zero_vel:
1507                self._va_sim_velocity = 0.0
1508            if self._va_sim_release_time is None:
1509                self._va_sim_release_time = self._va_sim_time
1510        self._va_sim_was_pressed = pressed
1511
1512        self._va_sim_time += dt
1513
1514        # Physics step
1515        if pressed:
1516            goal = target
1517            max_spd = ramp_rate
1518            acc = acceleration
1519        else:
1520            goal = rest
1521            max_spd = neg_ramp
1522            acc = neg_accel
1523
1524        # Physics step — modes are mutually exclusive:
1525        #   max_spd > 0: constant velocity (triangle wave)
1526        #   acc > 0:     v = u + a*t, no cap (hyperbolic curve)
1527        #   both 0:      instant jump
1528        diff = goal - self._va_sim_position
1529        if abs(diff) < 1e-9:
1530            self._va_sim_position = goal
1531            self._va_sim_velocity = 0.0
1532        elif max_spd == 0.0 and acc == 0.0:
1533            self._va_sim_position = goal
1534            self._va_sim_velocity = 0.0
1535        else:
1536            direction = 1.0 if diff > 0 else -1.0
1537            if max_spd > 0.0:
1538                self._va_sim_velocity = direction * max_spd
1539            else:
1540                self._va_sim_velocity += direction * acc * dt
1541            self._va_sim_position += self._va_sim_velocity * dt
1542            new_diff = goal - self._va_sim_position
1543            if (direction > 0 and new_diff < 0) or \
1544               (direction < 0 and new_diff > 0):
1545                self._va_sim_position = goal
1546                self._va_sim_velocity = 0.0
1547
1548        clamped = max(min_val, min(max_val, self._va_sim_position))
1549        if clamped != self._va_sim_position:
1550            self._va_sim_velocity = 0.0
1551            self._va_sim_position = clamped
1552        self._va_sim_trail.append(
1553            (self._va_sim_time, self._va_sim_position))
1554
1555        # Auto-extend X axis
1556        if self._va_sim_time > self._x_max - 0.2:
1557            self._x_max = self._va_sim_time + 1.0
1558
1559        self._draw()
1560
1561        # Stop condition: timeout only (sim keeps running to show off-time)
1562        if self._va_sim_time >= self._VA_SIM_TIMEOUT:
1563            self._va_sim_active = False
1564            self._status_var.set(
1565                "Simulation timed out — click Reset to start over")
1566            return
1567
1568        self._va_sim_after_id = self.after(
1569            self._VA_SIM_INTERVAL, self._va_sim_tick)
1570
1571    def _on_va_sim_reset(self):
1572        """Reset the simulation — clear trail and restore default view."""
1573        self._va_sim_stop()
1574        self._draw()
1575
1576    def _va_sim_stop(self):
1577        """Cancel any running VA simulation."""
1578        if self._va_sim_after_id is not None:
1579            self.after_cancel(self._va_sim_after_id)
1580            self._va_sim_after_id = None
1581        self._va_sim_active = False
1582        self._va_sim_pressed = False
1583        self._va_sim_trail = []
1584        self._va_sim_release_time = None
1585        if self._mode == "virtual_analog":
1586            self._x_max = self._va_total_duration
1587
1588    def _on_release(self, event):
1589        if self._drag_type in ("point", "handle") and self._drag_undo_pushed:
1590            self._save_to_action()
1591        self._drag_type = None
1592        if self._mode in ("spline", "segment"):
1593            self._status_var.set(
1594                "Click to add | Right-click to remove | Drag to adjust")
1595        elif self._mode in ("scaled", "squared"):
1596            self._status_var.set("Drag handle to adjust scale")
1597        elif self._mode == "threshold":
1598            self._status_var.set("Drag handle to adjust threshold")
1599        elif self._mode == "virtual_analog":
1600            self._status_var.set(
1601                "Drag handles to adjust target/rest values")
1602        elif self._mode == "raw":
1603            self._status_var.set("Read-only: raw input (no shaping)")
1604
1605    def _on_right_click(self, event):
1606        if self._mode not in ("spline", "segment"):
1607            return
1608        hit = self._hit_test(event.x, event.y)
1609        if not hit or hit[0] != "point":
1610            return
1611        i = hit[1]
1612        if i == 0 or i == len(self._points) - 1:
1613            self._status_var.set("Cannot remove endpoints")
1614            return
1615        if len(self._points) <= 2:
1616            self._status_var.set("Need at least 2 points")
1617            return
1618        self._drag_type = None
1619        self._remove_point(i)
1620
1621    # ------------------------------------------------------------------
1622    # Point Add / Remove
1623    # ------------------------------------------------------------------
1624
1625    def _add_point_at(self, cx, cy):
1626        x, vis_y = self._c2d(cx, cy)
1627        s = self._display_scale
1628        # Un-scale cursor Y to get raw point Y
1629        y = vis_y / s if abs(s) > 1e-6 else vis_y
1630
1631        if self._symmetric and x < -_MIN_X_GAP / 2:
1632            self._status_var.set(
1633                "Add points on the positive side (symmetry)")
1634            return
1635
1636        x_min = self._points[0]["x"]
1637        x_max = self._points[-1]["x"]
1638        if x <= x_min + _MIN_X_GAP or x >= x_max - _MIN_X_GAP:
1639            return
1640        y = max(-1.0, min(1.0, y))
1641
1642        for pt in self._points:
1643            if abs(pt["x"] - x) < _MIN_X_GAP:
1644                return
1645
1646        if self._mode == "segment" and self._monotonic:
1647            y = self._clamp_monotonic_insert(x, y)
1648
1649        self._push_undo()
1650        new_pt = {"x": round(x, 3), "y": round(y, 3)}
1651        if self._mode == "spline":
1652            new_pt["tangent"] = round(numerical_slope(self._points, x), 3)
1653        self._points.append(new_pt)
1654        self._points.sort(key=lambda p: p["x"])
1655
1656        if self._symmetric:
1657            self._enforce_symmetry()
1658
1659        self._save_to_action()
1660        self._draw()
1661        self._status_var.set(
1662            f"Added point at x={x:.2f} ({len(self._points)} points)")
1663
1664    def _remove_point(self, idx):
1665        if idx == 0 or idx == len(self._points) - 1:
1666            return
1667        if len(self._points) <= 2:
1668            return
1669        self._push_undo()
1670        self._points.pop(idx)
1671        if self._symmetric:
1672            self._enforce_symmetry()
1673        self._save_to_action()
1674        self._draw()
1675        self._status_var.set(
1676            f"Removed point ({len(self._points)} points)")
1677
1678    # ------------------------------------------------------------------
1679    # Monotonic Constraint (segment mode)
1680    # ------------------------------------------------------------------
1681
1682    def _clamp_monotonic(self, idx: int, y: float) -> float:
1683        if idx > 0:
1684            y = max(y, self._points[idx - 1]["y"])
1685        if idx < len(self._points) - 1:
1686            y = min(y, self._points[idx + 1]["y"])
1687        return y
1688
1689    def _clamp_monotonic_insert(self, x: float, y: float) -> float:
1690        lo_y = -1.0
1691        hi_y = 1.0
1692        for pt in self._points:
1693            if pt["x"] < x:
1694                lo_y = max(lo_y, pt["y"])
1695            elif pt["x"] > x:
1696                hi_y = min(hi_y, pt["y"])
1697                break
1698        return max(lo_y, min(hi_y, y))
1699
1700    def _enforce_monotonic(self):
1701        for i in range(1, len(self._points)):
1702            if self._points[i]["y"] < self._points[i - 1]["y"]:
1703                self._points[i]["y"] = self._points[i - 1]["y"]
1704
1705    # ------------------------------------------------------------------
1706    # Symmetry
1707    # ------------------------------------------------------------------
1708
1709    def _on_wide_range_toggle(self):
1710        """Toggle wide range mode for scaled/squared visualization."""
1711        self._draw()
1712
1713    def _on_processed_toggle(self):
1714        """Toggle display of scale and inversion on the curve."""
1715        self._draw()
1716
1717    def _on_symmetry_toggle(self):
1718        self._push_undo()
1719        self._symmetric = self._sym_var.get()
1720        if self._symmetric:
1721            self._enforce_symmetry()
1722            self._save_to_action()
1723            self._draw()
1724            self._status_var.set("Symmetry on — edit positive side")
1725
1726    def _enforce_symmetry(self):
1727        positive = [pt for pt in self._points
1728                    if pt["x"] > _MIN_X_GAP / 2]
1729        center = None
1730        for pt in self._points:
1731            if abs(pt["x"]) < _MIN_X_GAP / 2:
1732                center = pt
1733                break
1734
1735        is_spline = self._mode == "spline"
1736        if center is None:
1737            center = {"x": 0.0, "y": 0.0}
1738            if is_spline:
1739                center["tangent"] = 1.0
1740        else:
1741            center["x"] = 0.0
1742            center["y"] = 0.0
1743
1744        new_points = []
1745        for pt in reversed(positive):
1746            mirror = {"x": round(-pt["x"], 3), "y": round(-pt["y"], 3)}
1747            if is_spline:
1748                mirror["tangent"] = pt["tangent"]
1749            new_points.append(mirror)
1750        new_points.append(center)
1751        new_points.extend(positive)
1752        self._points = new_points
1753
1754    # ------------------------------------------------------------------
1755    # Undo
1756    # ------------------------------------------------------------------
1757
1758    def _push_undo(self):
1759        self._undo_stack.push(self._points)
1760
1761    def _on_ctrl_z(self, event):
1762        """Handle Ctrl+Z within the curve editor."""
1763        self._pop_undo()
1764        return "break"  # Prevent app-level bind_all undo from firing
1765
1766    def _pop_undo(self):
1767        state = self._undo_stack.pop()
1768        if state is None:
1769            self._status_var.set("Nothing to undo")
1770            return
1771        self._points = state
1772        self._save_to_action(push_app_undo=False)
1773        self._draw()
1774        self._status_var.set(f"Undo ({len(self._undo_stack)} remaining)")
1775
1776    # ------------------------------------------------------------------
1777    # Data Sync
1778    # ------------------------------------------------------------------
1779
1780    def _save_to_action(self, push_app_undo=True):
1781        """Write points back to the action and notify.
1782
1783        Args:
1784            push_app_undo: if True, push an undo snapshot to the app
1785                before saving. Set to False when called from _pop_undo
1786                so the curve editor's undo doesn't create a new app-level
1787                undo entry.
1788        """
1789        if not self._action:
1790            return
1791        if push_app_undo and self._on_before_change:
1792            self._on_before_change(200)
1793        if self._mode == "spline":
1794            self._action.extra[EXTRA_SPLINE_POINTS] = deepcopy(self._points)
1795        elif self._mode == "segment":
1796            self._action.extra[EXTRA_SEGMENT_POINTS] = deepcopy(self._points)
1797        if self._on_curve_changed:
1798            self._on_curve_changed()
1799
1800    # ------------------------------------------------------------------
1801    # Reset
1802    # ------------------------------------------------------------------
1803
1804    def _on_reset(self):
1805        if self._mode not in ("spline", "segment"):
1806            return
1807        self._push_undo()
1808        if self._mode == "spline":
1809            self._points = default_spline_points()
1810        else:
1811            self._points = default_segment_points()
1812        if self._symmetric:
1813            self._enforce_symmetry()
1814        if self._mode == "segment" and self._monotonic:
1815            self._enforce_monotonic()
1816        self._save_to_action()
1817        self._draw()
1818        self._status_var.set("Reset to linear")
1819
1820    def _on_monotonic_toggle(self):
1821        self._push_undo()
1822        self._monotonic = self._mono_var.get()
1823        if self._monotonic:
1824            self._enforce_monotonic()
1825            self._save_to_action()
1826            self._draw()
1827            self._status_var.set(
1828                "Monotonic on — output increases with input")
1829
1830    # ------------------------------------------------------------------
1831    # Import / Export / Copy
1832    # ------------------------------------------------------------------
1833
1834    def _on_export(self):
1835        if self._mode not in ("spline", "segment"):
1836            return
1837        curve_type = "spline" if self._mode == "spline" else "segment"
1838        path = filedialog.asksaveasfilename(
1839            parent=self.winfo_toplevel(),
1840            title=f"Export {curve_type.title()} Curve",
1841            defaultextension=".yaml",
1842            filetypes=[("YAML files", "*.yaml *.yml"),
1843                       ("All files", "*.*")])
1844        if not path:
1845            return
1846        data = {"type": curve_type, "points": deepcopy(self._points)}
1847        with open(path, "w") as f:
1848            yaml.dump(data, f, default_flow_style=False, sort_keys=False)
1849        self._status_var.set(f"Exported to {path}")
1850
1851    def _on_import(self):
1852        if self._mode not in ("spline", "segment"):
1853            return
1854        path = filedialog.askopenfilename(
1855            parent=self.winfo_toplevel(),
1856            title="Import Curve",
1857            filetypes=[("YAML files", "*.yaml *.yml"),
1858                       ("All files", "*.*")])
1859        if not path:
1860            return
1861        try:
1862            with open(path) as f:
1863                data = yaml.safe_load(f)
1864        except Exception as exc:
1865            messagebox.showerror("Import Failed",
1866                                 f"Could not read YAML file:\n{exc}",
1867                                 parent=self.winfo_toplevel())
1868            return
1869
1870        if isinstance(data, dict):
1871            points = data.get("points", [])
1872        elif isinstance(data, list):
1873            points = data
1874        else:
1875            messagebox.showerror(
1876                "Import Failed",
1877                "File does not contain curve data.",
1878                parent=self.winfo_toplevel())
1879            return
1880
1881        if not points or not isinstance(points, list) or not all(
1882                isinstance(p, dict) and "x" in p and "y" in p
1883                for p in points):
1884            messagebox.showerror(
1885                "Import Failed",
1886                "Invalid point data. Each point must have 'x' and 'y'.",
1887                parent=self.winfo_toplevel())
1888            return
1889
1890        self._push_undo()
1891        if self._mode == "spline":
1892            for p in points:
1893                if "tangent" not in p:
1894                    p["tangent"] = 1.0
1895            self._points = [dict(p) for p in points]
1896        else:
1897            self._points = [{"x": p["x"], "y": p["y"]} for p in points]
1898        self._points.sort(key=lambda p: p["x"])
1899        self._save_to_action()
1900        self._draw()
1901        self._status_var.set(f"Imported from {path}")
1902
1903    def _on_copy_from(self):
1904        if self._mode not in ("spline", "segment"):
1905            return
1906        curves = {}
1907        if self._get_other_curves:
1908            curves = self._get_other_curves(self._mode)
1909        if not curves:
1910            self._status_var.set("No other curves available")
1911            return
1912
1913        win = tk.Toplevel(self.winfo_toplevel())
1914        win.title("Copy Curve From...")
1915        win.transient(self.winfo_toplevel())
1916        win.grab_set()
1917        win.resizable(False, False)
1918
1919        ttk.Label(win, text="Select an action to copy its curve:",
1920                  padding=5).pack(anchor=tk.W)
1921        listbox = tk.Listbox(win, height=min(10, len(curves)), width=40)
1922        listbox.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
1923        names = sorted(curves.keys())
1924        for name in names:
1925            listbox.insert(tk.END, name)
1926
1927        def on_ok():
1928            sel = listbox.curselection()
1929            if not sel:
1930                return
1931            chosen = names[sel[0]]
1932            pts = curves[chosen]
1933            self._push_undo()
1934            if self._mode == "spline":
1935                for p in pts:
1936                    if "tangent" not in p:
1937                        p["tangent"] = 1.0
1938                self._points = [dict(p) for p in pts]
1939            else:
1940                self._points = [{"x": p["x"], "y": p["y"]} for p in pts]
1941            self._points.sort(key=lambda p: p["x"])
1942            self._save_to_action()
1943            self._draw()
1944            self._status_var.set(f"Copied curve from {chosen}")
1945            win.destroy()
1946
1947        listbox.bind("<Double-1>", lambda e: on_ok())
1948        bf = ttk.Frame(win)
1949        bf.pack(fill=tk.X, padx=10, pady=(0, 10))
1950        ttk.Button(bf, text="OK", command=on_ok).pack(
1951            side=tk.RIGHT, padx=5)
1952        ttk.Button(bf, text="Cancel",
1953                   command=win.destroy).pack(side=tk.RIGHT)
1954
1955        win.update_idletasks()
1956        px = self.winfo_toplevel().winfo_rootx()
1957        py = self.winfo_toplevel().winfo_rooty()
1958        pw = self.winfo_toplevel().winfo_width()
1959        ph = self.winfo_toplevel().winfo_height()
1960        ww, wh = win.winfo_width(), win.winfo_height()
1961        win.geometry(f"+{px + (pw - ww) // 2}+{py + (ph - wh) // 2}")
class CurveEditorWidget(tkinter.ttk.Frame):
  89class CurveEditorWidget(ttk.Frame):
  90    """Embeddable curve editor supporting spline, segment, and visualization
  91    modes.
  92
  93    Replaces the Phase 2 placeholder in the lower-left pane of the
  94    Action Editor tab.
  95    """
  96
  97    def __init__(self, parent, *,
  98                 on_before_change=None,
  99                 on_curve_changed=None,
 100                 get_other_curves=None,
 101                 get_advanced_flags=None):
 102        super().__init__(parent)
 103
 104        self._on_before_change = on_before_change
 105        self._on_curve_changed = on_curve_changed
 106        self._get_other_curves = get_other_curves
 107        self._get_advanced_flags = get_advanced_flags or (
 108            lambda: {"splines": True, "nonmono": True})
 109
 110        self._action: ActionDefinition | None = None
 111        self._qname: str | None = None
 112        self._mode: str | None = None  # spline/segment/raw/scaled/squared
 113        self._points: list[dict] = []
 114
 115        # Drag state
 116        self._drag_type = None   # "point", "handle", or "scale_handle"
 117        self._drag_idx = None
 118        self._drag_side = None   # "in" or "out" (spline handles)
 119        self._drag_undo_pushed = False
 120
 121        # Options
 122        self._symmetric = False
 123        self._monotonic = True
 124
 125        # Virtual Analog state
 126        self._va_press_duration = 1.5
 127        self._va_total_duration = 3.0
 128
 129        # VA live simulation state
 130        self._va_sim_active = False
 131        self._va_sim_pressed = False
 132        self._va_sim_position = 0.0
 133        self._va_sim_velocity = 0.0
 134        self._va_sim_time = 0.0
 135        self._va_sim_trail = []
 136        self._va_sim_after_id = None
 137        self._va_sim_was_pressed = False
 138        self._va_sim_release_time = None
 139
 140        # Undo stack (max 30)
 141        self._undo_stack = UndoStack()
 142
 143        # Canvas sizing (computed on configure)
 144        self._margin_x = 35
 145        self._margin_y = 35
 146        self._plot_w = 0
 147        self._plot_h = 0
 148
 149        # X-axis range: -1..1 for sticks, 0..1 for triggers, 0..N for VA
 150        self._x_min = -1.0
 151        self._x_max = 1.0
 152
 153        # Y-axis range: defaults to (-1, 1), auto-scaled for visualization
 154        self._y_min = -1.0
 155        self._y_max = 1.0
 156
 157        self._build_ui()
 158
 159    # ------------------------------------------------------------------
 160    # UI Construction
 161    # ------------------------------------------------------------------
 162
 163    def _build_ui(self):
 164        # Toolbar row 1: checkboxes
 165        self._toolbar = ttk.Frame(self)
 166        self._toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 167
 168        self._sym_var = tk.BooleanVar()
 169        self._sym_cb = ttk.Checkbutton(
 170            self._toolbar, text="Symmetry",
 171            variable=self._sym_var, command=self._on_symmetry_toggle)
 172        self._sym_cb.pack(side=tk.LEFT, padx=3)
 173
 174        self._mono_var = tk.BooleanVar(value=True)
 175        self._mono_cb = ttk.Checkbutton(
 176            self._toolbar, text="Monotonic",
 177            variable=self._mono_var, command=self._on_monotonic_toggle)
 178        self._mono_cb.pack(side=tk.LEFT, padx=3)
 179
 180        self._proc_var = tk.BooleanVar(value=True)
 181        self._proc_cb = ttk.Checkbutton(
 182            self._toolbar, text="Show Processed",
 183            variable=self._proc_var, command=self._on_processed_toggle)
 184        # Packed dynamically in _update_toolbar after the checkboxes
 185
 186        # Toolbar row 2: action buttons
 187        self._toolbar2 = ttk.Frame(self)
 188
 189        self._undo_btn = ttk.Button(
 190            self._toolbar2, text="Undo", command=self._pop_undo, width=5)
 191        self._undo_btn.pack(side=tk.LEFT, padx=3)
 192
 193        self._reset_btn = ttk.Button(
 194            self._toolbar2, text="Reset", command=self._on_reset, width=5)
 195        self._reset_btn.pack(side=tk.LEFT, padx=3)
 196
 197        self._export_btn = ttk.Button(
 198            self._toolbar2, text="Export", command=self._on_export, width=6)
 199        self._export_btn.pack(side=tk.LEFT, padx=3)
 200
 201        self._import_btn = ttk.Button(
 202            self._toolbar2, text="Import", command=self._on_import, width=6)
 203        self._import_btn.pack(side=tk.LEFT, padx=3)
 204
 205        self._copy_btn = ttk.Button(
 206            self._toolbar2, text="Copy from...",
 207            command=self._on_copy_from, width=10)
 208        self._copy_btn.pack(side=tk.LEFT, padx=3)
 209
 210        # Visualization toolbar (shown for scaled/squared modes)
 211        self._vis_toolbar = ttk.Frame(self)
 212        self._wide_range_var = tk.BooleanVar(value=False)
 213        self._wide_range_cb = ttk.Checkbutton(
 214            self._vis_toolbar, text="Wide range",
 215            variable=self._wide_range_var,
 216            command=self._on_wide_range_toggle)
 217        self._wide_range_cb.pack(side=tk.LEFT, padx=3)
 218
 219        # VA simulate button (hold to simulate press, release to simulate release)
 220        self._va_sim_btn = ttk.Button(
 221            self._vis_toolbar, text="Hold to Simulate")
 222        self._va_sim_btn.bind("<ButtonPress-1>", self._on_va_sim_press)
 223        self._va_sim_btn.bind("<ButtonRelease-1>", self._on_va_sim_release)
 224        self._va_reset_btn = ttk.Button(
 225            self._vis_toolbar, text="Reset",
 226            command=self._on_va_sim_reset)
 227
 228        # Canvas (fills remaining space)
 229        self._canvas = tk.Canvas(self, bg=BG_INACTIVE,
 230                                 highlightthickness=0)
 231        self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 232
 233        self._canvas.bind("<Configure>", self._on_canvas_configure)
 234        self._canvas.bind("<ButtonPress-1>", self._on_press)
 235        self._canvas.bind("<B1-Motion>", self._on_drag)
 236        self._canvas.bind("<ButtonRelease-1>", self._on_release)
 237        self._canvas.bind("<Button-3>", self._on_right_click)
 238        self._canvas.bind("<Motion>", self._on_mouse_move)
 239        self._canvas.bind("<Leave>", self._on_mouse_leave)
 240
 241        # Status bar (bottom)
 242        self._status_var = tk.StringVar(value="No action selected")
 243        ttk.Label(self, textvariable=self._status_var,
 244                  relief=tk.SUNKEN, anchor=tk.W,
 245                  font=("TkDefaultFont", 7)).pack(
 246                      fill=tk.X, padx=2, pady=(0, 2))
 247
 248        self.bind("<Control-z>", self._on_ctrl_z)
 249
 250        # Start with toolbars hidden
 251        self._toolbar.pack_forget()
 252        self._toolbar2.pack_forget()
 253
 254    # ------------------------------------------------------------------
 255    # Dynamic Canvas Sizing
 256    # ------------------------------------------------------------------
 257
 258    def _on_canvas_configure(self, event):
 259        w, h = event.width, event.height
 260        if w < 20 or h < 20:
 261            return
 262
 263        margin_x = max(25, min(50, int(w * 0.08)))
 264        margin_y = max(25, min(50, int(h * 0.08)))
 265
 266        available_w = w - 2 * margin_x
 267        available_h = h - 2 * margin_y
 268
 269        # Square plot area
 270        plot_size = max(10, min(available_w, available_h))
 271
 272        self._margin_x = margin_x + (available_w - plot_size) // 2
 273        self._margin_y = margin_y + (available_h - plot_size) // 2
 274        self._plot_w = plot_size
 275        self._plot_h = plot_size
 276
 277        self._draw()
 278
 279    # ------------------------------------------------------------------
 280    # Coordinate Conversion
 281    # ------------------------------------------------------------------
 282
 283    def _d2c(self, x: float, y: float) -> tuple[float, float]:
 284        """Data coords to canvas pixels. X uses _x_min.._x_max, Y uses _y_min/_y_max."""
 285        x_range = self._x_max - self._x_min
 286        if x_range == 0:
 287            x_range = 2.0
 288        cx = self._margin_x + (x - self._x_min) / x_range * self._plot_w
 289        y_range = self._y_max - self._y_min
 290        if y_range == 0:
 291            y_range = 2.0
 292        cy = self._margin_y + (self._y_max - y) / y_range * self._plot_h
 293        return cx, cy
 294
 295    def _c2d(self, cx: float, cy: float) -> tuple[float, float]:
 296        """Canvas pixels to data coords."""
 297        if self._plot_w == 0 or self._plot_h == 0:
 298            return 0.0, 0.0
 299        x_range = self._x_max - self._x_min
 300        if x_range == 0:
 301            x_range = 2.0
 302        x = (cx - self._margin_x) / self._plot_w * x_range + self._x_min
 303        y_range = self._y_max - self._y_min
 304        if y_range == 0:
 305            y_range = 2.0
 306        y = self._y_max - (cy - self._margin_y) / self._plot_h * y_range
 307        return x, y
 308
 309    @property
 310    def _display_scale(self) -> float:
 311        """Scale factor applied to displayed Y values in editable modes.
 312
 313        Includes inversion as a sign flip so the curve visually reflects
 314        the runtime shaping pipeline.  This is exact for odd-symmetric
 315        curves (the common case) and a close approximation otherwise.
 316
 317        Controlled by the "Show Processed" checkbox — when unchecked,
 318        returns 1.0 (raw view).
 319        """
 320        if (self._action and self._mode in ("spline", "segment")
 321                and self._proc_var.get()):
 322            s = self._action.scale
 323            if self._action.inversion:
 324                s = -s
 325            return s
 326        return 1.0
 327
 328    @property
 329    def _handle_length(self) -> float:
 330        return max(20, self._plot_w * 0.1)
 331
 332    def _tangent_offset(self, tangent: float) -> tuple[float, float]:
 333        """Tangent slope to canvas-pixel offset for handle drawing.
 334
 335        Accounts for display scale and dynamic Y range so handles
 336        visually match the scaled curve direction.
 337        """
 338        # Pixels per data unit in each axis
 339        x_range = 1.0 - self._x_min
 340        if x_range == 0:
 341            x_range = 2.0
 342        ppx = self._plot_w / x_range
 343        y_range = self._y_max - self._y_min
 344        if y_range == 0:
 345            y_range = 2.0
 346        ppy = self._plot_h / y_range
 347        if ppx < 1 or ppy < 1:
 348            return self._handle_length, 0.0
 349        # Tangent is raw dy/dx; scale it for display
 350        vis_tangent = tangent * self._display_scale
 351        dx = 1.0 * ppx
 352        dy = -vis_tangent * ppy
 353        length = math.hypot(dx, dy)
 354        if length < 1e-6:
 355            return self._handle_length, 0.0
 356        s = self._handle_length / length
 357        return dx * s, dy * s
 358
 359    def _offset_to_tangent(self, dx: float, dy: float) -> float:
 360        """Canvas-pixel offset back to raw tangent slope (un-scaled)."""
 361        x_range = 1.0 - self._x_min
 362        if x_range == 0:
 363            x_range = 2.0
 364        ppx = self._plot_w / x_range
 365        y_range = self._y_max - self._y_min
 366        if y_range == 0:
 367            y_range = 2.0
 368        ppy = self._plot_h / y_range
 369        if ppx < 1 or ppy < 1:
 370            return 1.0
 371        data_dx = dx / ppx
 372        data_dy = -dy / ppy
 373        if abs(data_dx) < 1e-6:
 374            return 10.0 if data_dy > 0 else -10.0
 375        vis_tangent = data_dy / data_dx
 376        # Un-scale to get raw tangent
 377        s = self._display_scale
 378        if abs(s) < 1e-6:
 379            return vis_tangent
 380        return vis_tangent / s
 381
 382    # ------------------------------------------------------------------
 383    # Public API
 384    # ------------------------------------------------------------------
 385
 386    # Input names that only produce 0..1 (Xbox triggers)
 387    _TRIGGER_INPUTS = TRIGGER_INPUTS
 388
 389    def on_advanced_changed(self):
 390        """Refresh UI elements affected by Advanced menu toggles."""
 391        if self._mode in ("spline", "segment"):
 392            self._update_toolbar()
 393
 394    def load_action(self, action: ActionDefinition, qname: str,
 395                    bound_inputs: list[str] | None = None):
 396        """Populate the widget from the given action.
 397
 398        Args:
 399            bound_inputs: list of input names bound to this action.
 400                If all are trigger inputs (0..1 range), the X axis
 401                adjusts from -1..1 to 0..1.
 402        """
 403        self._va_sim_stop()
 404        self._action = action
 405        self._qname = qname
 406        self._undo_stack.clear()
 407        self._drag_type = None
 408        self._symmetric = False
 409        self._sym_var.set(False)
 410        self._monotonic = True
 411        self._mono_var.set(True)
 412
 413        # Determine mode from input_type + trigger_mode
 414        if action.input_type == InputType.VIRTUAL_ANALOG:
 415            self._mode = "virtual_analog"
 416        elif action.input_type == InputType.BOOLEAN_TRIGGER:
 417            self._mode = "threshold"
 418        elif action.input_type != InputType.ANALOG:
 419            self._mode = None
 420        elif action.trigger_mode == EventTriggerMode.SPLINE:
 421            self._mode = "spline"
 422        elif action.trigger_mode == EventTriggerMode.SEGMENTED:
 423            self._mode = "segment"
 424        elif action.trigger_mode == EventTriggerMode.RAW:
 425            self._mode = "raw"
 426        elif action.trigger_mode == EventTriggerMode.SCALED:
 427            self._mode = "scaled"
 428        elif action.trigger_mode == EventTriggerMode.SQUARED:
 429            self._mode = "squared"
 430        else:
 431            self._mode = None
 432
 433        # Set X range (must be after mode is determined)
 434        self._update_x_range(bound_inputs)
 435
 436        # Load points for editable modes
 437        if self._mode == "spline":
 438            pts = action.extra.get(EXTRA_SPLINE_POINTS)
 439            if not pts:
 440                pts = default_spline_points()
 441                action.extra[EXTRA_SPLINE_POINTS] = pts
 442            self._points = [dict(p) for p in pts]
 443            self._points.sort(key=lambda p: p["x"])
 444        elif self._mode == "segment":
 445            pts = action.extra.get(EXTRA_SEGMENT_POINTS)
 446            if not pts:
 447                pts = default_segment_points()
 448                action.extra[EXTRA_SEGMENT_POINTS] = pts
 449            self._points = [{"x": p["x"], "y": p["y"]} for p in pts]
 450            self._points.sort(key=lambda p: p["x"])
 451        else:
 452            self._points = []
 453
 454        self._update_toolbar()
 455        self._update_canvas_bg()
 456        self._draw()
 457
 458    def clear(self):
 459        """Clear to inactive state."""
 460        self._va_sim_stop()
 461        self._action = None
 462        self._qname = None
 463        self._mode = None
 464        self._points = []
 465        self._undo_stack.clear()
 466        self._drag_type = None
 467        self._update_toolbar()
 468        self._update_canvas_bg()
 469        self._draw()
 470
 471    def get_mode(self) -> str | None:
 472        return self._mode
 473
 474    def refresh(self):
 475        """Redraw from current action (call after external parameter change)."""
 476        if self._action:
 477            # Re-determine mode in case trigger_mode changed
 478            old_mode = self._mode
 479            self.load_action(self._action, self._qname)
 480            # Don't reset undo if mode didn't change
 481            if old_mode == self._mode:
 482                pass  # undo already cleared by load_action; acceptable
 483        else:
 484            self._draw()
 485
 486    def update_bindings(self, bound_inputs: list[str] | None = None):
 487        """Update X range when bindings change (assign/unassign)."""
 488        old_x_min = self._x_min
 489        self._update_x_range(bound_inputs)
 490        if self._x_min != old_x_min:
 491            self._draw()
 492
 493    def _update_x_range(self, bound_inputs: list[str] | None):
 494        """Set X range based on bound input types.
 495
 496        If ALL bound inputs are triggers (0..1), use 0..1.
 497        Otherwise use -1..1 (sticks, or no bindings).
 498        VA mode uses 0..total_duration (time axis).
 499        None means 'keep current' (e.g. refresh without rebinding).
 500        """
 501        if self._mode == "virtual_analog":
 502            self._x_min = 0.0
 503            self._x_max = self._va_total_duration
 504            return
 505        self._x_max = 1.0
 506        if bound_inputs is None:
 507            return
 508        if bound_inputs and all(
 509                inp in self._TRIGGER_INPUTS for inp in bound_inputs):
 510            self._x_min = 0.0
 511        else:
 512            self._x_min = -1.0
 513
 514    # ------------------------------------------------------------------
 515    # Toolbar Management
 516    # ------------------------------------------------------------------
 517
 518    def _update_toolbar(self):
 519        """Show/hide toolbar rows and mode-specific controls."""
 520        is_editable = self._mode in ("spline", "segment")
 521        is_vis_draggable = self._mode in ("scaled", "squared")
 522        is_va = self._mode == "virtual_analog"
 523        if is_editable:
 524            self._vis_toolbar.pack_forget()
 525            self._toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 526            self._toolbar2.pack(fill=tk.X, padx=2, pady=(0, 0))
 527            # Repack canvas after toolbars
 528            self._canvas.pack_forget()
 529            self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 530            # Show/hide monotonic and position Show Processed
 531            if self._mode == "segment":
 532                self._mono_cb.pack(side=tk.LEFT, padx=3,
 533                                   after=self._sym_cb)
 534                flags = self._get_advanced_flags()
 535                if not flags["nonmono"]:
 536                    self._mono_var.set(True)
 537                    self._mono_cb.config(state="disabled")
 538                else:
 539                    self._mono_cb.config(state="normal")
 540                self._proc_cb.pack(side=tk.LEFT, padx=3,
 541                                   after=self._mono_cb)
 542            else:
 543                self._mono_cb.pack_forget()
 544                self._proc_cb.pack(side=tk.LEFT, padx=3,
 545                                   after=self._sym_cb)
 546        elif is_vis_draggable or is_va:
 547            self._toolbar.pack_forget()
 548            self._toolbar2.pack_forget()
 549            self._proc_cb.pack_forget()
 550            self._vis_toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
 551            # Show/hide VA sim buttons
 552            if is_va:
 553                self._va_sim_btn.pack(side=tk.LEFT, padx=3)
 554                self._va_reset_btn.pack(side=tk.LEFT, padx=3)
 555            else:
 556                self._va_sim_btn.pack_forget()
 557                self._va_reset_btn.pack_forget()
 558            # Repack canvas after vis toolbar
 559            self._canvas.pack_forget()
 560            self._canvas.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
 561        else:
 562            self._toolbar.pack_forget()
 563            self._toolbar2.pack_forget()
 564            self._proc_cb.pack_forget()
 565            self._va_sim_btn.pack_forget()
 566            self._va_reset_btn.pack_forget()
 567            self._vis_toolbar.pack_forget()
 568
 569    def _update_canvas_bg(self):
 570        bg = BG_INACTIVE if self._mode is None else BG_WHITE
 571        self._canvas.configure(bg=bg)
 572        if self._mode in ("spline", "segment"):
 573            cursor = "crosshair"
 574        elif self._mode == "threshold":
 575            cursor = "sb_h_double_arrow"
 576        elif self._mode == "virtual_analog":
 577            cursor = "sb_v_double_arrow"
 578        else:
 579            cursor = ""
 580        self._canvas.configure(cursor=cursor)
 581        if self._mode is None:
 582            self._status_var.set("No action selected")
 583        elif self._mode == "raw":
 584            self._status_var.set("Read-only: raw input (no shaping)")
 585        elif self._mode in ("scaled", "squared"):
 586            self._status_var.set("Drag handle to adjust scale")
 587        elif self._mode == "spline":
 588            self._status_var.set(
 589                "Click to add | Right-click to remove | Drag to adjust")
 590        elif self._mode == "segment":
 591            self._status_var.set(
 592                "Click to add | Right-click to remove | Drag to adjust")
 593        elif self._mode == "threshold":
 594            self._status_var.set("Drag handle to adjust threshold")
 595        elif self._mode == "virtual_analog":
 596            self._status_var.set(
 597                "Drag handles to adjust target/rest values")
 598
 599    # ------------------------------------------------------------------
 600    # Drawing
 601    # ------------------------------------------------------------------
 602
 603    def _compute_y_range(self):
 604        """Compute Y-axis range, auto-scaling when curve extends beyond -1..1."""
 605        if (self._mode in ("raw", "scaled", "squared")
 606                and self._action and self._wide_range_var.get()):
 607            y_min = 0.0
 608            y_max = 0.0
 609            x_span = 1.0 - self._x_min
 610            for i in range(_VIS_SAMPLES + 1):
 611                x = self._x_min + x_span * i / _VIS_SAMPLES
 612                y = self._compute_shaped_value(x)
 613                y_min = min(y_min, y)
 614                y_max = max(y_max, y)
 615            # Ensure range includes at least -1..1
 616            y_min = min(y_min, -1.0)
 617            y_max = max(y_max, 1.0)
 618            pad = (y_max - y_min) * 0.05
 619            self._y_min = y_min - pad
 620            self._y_max = y_max + pad
 621        elif self._mode in ("spline", "segment") and self._action:
 622            s = abs(self._display_scale)
 623            # Find actual curve extent (spline can overshoot control points)
 624            if s > 1e-6 and self._points and len(self._points) >= 2:
 625                y_min = 0.0
 626                y_max = 0.0
 627                for pt in self._points:
 628                    y_min = min(y_min, pt["y"] * self._display_scale)
 629                    y_max = max(y_max, pt["y"] * self._display_scale)
 630                if self._mode == "spline":
 631                    pts = self._points
 632                    n = 20 * (len(pts) - 1)
 633                    x_lo, x_hi = pts[0]["x"], pts[-1]["x"]
 634                    for i in range(n + 1):
 635                        x = x_lo + (x_hi - x_lo) * i / n
 636                        y = evaluate_spline(pts, x) * self._display_scale
 637                        y_min = min(y_min, y)
 638                        y_max = max(y_max, y)
 639                y_min = min(y_min, -1.0)
 640                y_max = max(y_max, 1.0)
 641                pad = (y_max - y_min) * 0.05
 642                self._y_min = y_min - pad
 643                self._y_max = y_max + pad
 644            else:
 645                self._y_min = -1.0
 646                self._y_max = 1.0
 647        elif self._mode == "threshold":
 648            self._y_min = -0.1
 649            self._y_max = 1.1
 650        elif self._mode == "virtual_analog" and self._action:
 651            target = float(self._action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
 652            rest = float(self._action.extra.get(EXTRA_VA_REST_VALUE, 0.0))
 653            lo = min(target, rest)
 654            hi = max(target, rest)
 655            pad = max((hi - lo) * 0.15, 0.1)
 656            self._y_min = lo - pad
 657            self._y_max = hi + pad
 658        else:
 659            self._y_min = -1.0
 660            self._y_max = 1.0
 661
 662    def _draw(self):
 663        c = self._canvas
 664        c.delete("all")
 665
 666        if self._plot_w < 10 or self._plot_h < 10:
 667            return
 668
 669        if self._mode is None:
 670            self._draw_inactive()
 671            return
 672
 673        self._compute_y_range()
 674        self._draw_grid()
 675
 676        if self._mode == "virtual_analog":
 677            if self._va_sim_active or self._va_sim_trail:
 678                self._draw_va_sim_trail()
 679            else:
 680                self._draw_va_ramp()
 681            self._draw_va_handles()
 682        elif self._mode in ("raw", "scaled", "squared"):
 683            self._draw_deadband_band()
 684            self._draw_computed_curve()
 685            if self._mode in ("scaled", "squared"):
 686                self._draw_scale_handle()
 687        elif self._mode == "threshold":
 688            self._draw_threshold_curve()
 689            self._draw_threshold_handle()
 690        elif self._mode == "spline":
 691            self._draw_spline_curve()
 692            self._draw_handles()
 693            self._draw_points()
 694        elif self._mode == "segment":
 695            self._draw_segment_curve()
 696            self._draw_points()
 697
 698    def _draw_inactive(self):
 699        c = self._canvas
 700        cw = int(c.cget("width")) if c.winfo_width() < 2 else c.winfo_width()
 701        ch = int(c.cget("height")) if c.winfo_height() < 2 else c.winfo_height()
 702        c.create_text(cw // 2, ch // 2,
 703                      text="Select an analog or virtual analog\naction to view curve",
 704                      fill="#999999", font=("TkDefaultFont", 10))
 705
 706    def _nice_grid_step(self, span: float) -> float:
 707        """Choose a nice gridline step for the given data span."""
 708        return nice_grid_step(span)
 709
 710    def _draw_grid(self):
 711        c = self._canvas
 712        small = self._plot_w < 200
 713
 714        # X gridlines (dynamic based on x range)
 715        if self._mode == "virtual_analog":
 716            x_step = self._nice_grid_step(self._x_max - self._x_min)
 717            x_grid = []
 718            v = 0.0
 719            while v <= self._x_max + x_step * 0.01:
 720                x_grid.append(v)
 721                v += x_step
 722        elif self._x_min >= 0:
 723            x_grid = [i / 4 for i in range(0, 5)]  # 0, 0.25, 0.5, 0.75, 1
 724        else:
 725            x_grid = [i / 4 for i in range(-4, 5)]  # -1..1 by 0.25
 726        for v in x_grid:
 727            cx, _ = self._d2c(v, 0)
 728            is_axis = abs(v) < 0.01
 729            is_major = abs(v * 2) % 1 < 0.01
 730            if small and not is_axis and not is_major:
 731                continue
 732            color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 733            w = 2 if is_axis else 1
 734            c.create_line(cx, self._margin_y,
 735                          cx, self._margin_y + self._plot_h,
 736                          fill=color, width=w)
 737
 738        # Y gridlines (dynamic range)
 739        y_step = self._nice_grid_step(self._y_max - self._y_min)
 740        # Start from a rounded value below y_min
 741        y_start = math.floor(self._y_min / y_step) * y_step
 742        v = y_start
 743        while v <= self._y_max + y_step * 0.01:
 744            _, cy = self._d2c(0, v)
 745            is_axis = abs(v) < y_step * 0.01
 746            color = GRID_AXIS if is_axis else GRID_MAJOR
 747            w = 2 if is_axis else 1
 748            c.create_line(self._margin_x, cy,
 749                          self._margin_x + self._plot_w, cy,
 750                          fill=color, width=w)
 751            v += y_step
 752
 753        # Labels
 754        if self._plot_w >= 100:
 755            font_size = 7 if self._plot_w < 250 else 8
 756            # X labels
 757            if self._mode == "virtual_analog":
 758                x_labels = x_grid
 759            elif self._x_min >= 0:
 760                x_labels = [0.0, 0.25, 0.5, 0.75, 1.0]
 761            else:
 762                x_labels = [-1.0, -0.5, 0.0, 0.5, 1.0]
 763            for v in x_labels:
 764                cx, _ = self._d2c(v, 0)
 765                label = f"{v:g}s" if self._mode == "virtual_analog" else f"{v:g}"
 766                c.create_text(cx, self._margin_y + self._plot_h + 12,
 767                              text=label, fill=LABEL_COLOR,
 768                              font=("TkDefaultFont", font_size))
 769            # Y labels
 770            v = y_start
 771            while v <= self._y_max + y_step * 0.01:
 772                _, cy = self._d2c(0, v)
 773                c.create_text(self._margin_x - 18, cy,
 774                              text=f"{v:g}", fill=LABEL_COLOR,
 775                              font=("TkDefaultFont", font_size))
 776                v += y_step
 777
 778        # Reference lines at ±1 when Y range extends beyond -1..1
 779        if self._y_min < -1.05 or self._y_max > 1.05:
 780            for ref_y in (-1.0, 1.0):
 781                _, ry = self._d2c(0, ref_y)
 782                c.create_line(self._margin_x, ry,
 783                              self._margin_x + self._plot_w, ry,
 784                              fill="#b0b0ff", width=1, dash=(6, 3))
 785
 786        # Border
 787        c.create_rectangle(self._margin_x, self._margin_y,
 788                           self._margin_x + self._plot_w,
 789                           self._margin_y + self._plot_h,
 790                           outline="#808080")
 791
 792    # --- Visualization Modes ---
 793
 794    def _compute_shaped_value(self, x: float) -> float:
 795        """Compute the shaped output for visualization modes."""
 796        if not self._action:
 797            return x
 798
 799        # RAW bypasses all shaping
 800        if self._mode == "raw":
 801            return x
 802
 803        val = x
 804
 805        # 1. Inversion
 806        if self._action.inversion:
 807            val = -val
 808
 809        # 2. Deadband
 810        db = self._action.deadband
 811        if db > 0 and abs(val) < db:
 812            val = 0.0
 813        elif db > 0:
 814            sign = 1.0 if val >= 0 else -1.0
 815            val = sign * (abs(val) - db) / (1.0 - db) if db < 1.0 else 0.0
 816
 817        # 3. Curve function
 818        if self._mode == "squared":
 819            sign = 1.0 if val >= 0 else -1.0
 820            val = sign * val * val
 821
 822        # 4. Scale
 823        val = val * self._action.scale
 824
 825        return val
 826
 827    def _draw_computed_curve(self):
 828        """Draw the visualization curve for raw/scaled/squared modes."""
 829        c = self._canvas
 830        coords = []
 831        x_span = 1.0 - self._x_min
 832        for i in range(_VIS_SAMPLES + 1):
 833            x = self._x_min + x_span * i / _VIS_SAMPLES
 834            y = self._compute_shaped_value(x)
 835            cx, cy = self._d2c(x, y)
 836            coords.extend([cx, cy])
 837        if len(coords) >= 4:
 838            c.create_line(*coords, fill=CURVE_LINE, width=2, smooth=False)
 839
 840    def _draw_deadband_band(self):
 841        """Draw shaded deadband region."""
 842        if not self._action or self._action.deadband <= 0:
 843            return
 844        db = self._action.deadband
 845        x0, y0 = self._d2c(-db, self._y_max)
 846        x1, y1 = self._d2c(db, self._y_min)
 847        self._canvas.create_rectangle(
 848            x0, y0, x1, y1,
 849            fill=_DEADBAND_FILL, outline="", stipple="gray25")
 850
 851    def _draw_scale_handle(self):
 852        """Draw draggable scale handle at (1.0, f(1.0))."""
 853        if not self._action:
 854            return
 855        y_val = self._compute_shaped_value(1.0)
 856        cx, cy = self._d2c(1.0, y_val)
 857        r = _POINT_RADIUS + 1
 858        # Diamond shape
 859        self._canvas.create_polygon(
 860            cx, cy - r, cx + r, cy, cx, cy + r, cx - r, cy,
 861            fill=_SCALE_HANDLE, outline=_SCALE_HANDLE_OUTLINE, width=2)
 862
 863    # --- Threshold Mode ---
 864
 865    def _draw_threshold_curve(self):
 866        """Draw a step function: 0 below threshold, 1 at/above threshold."""
 867        if not self._action:
 868            return
 869        c = self._canvas
 870        t = self._action.threshold
 871
 872        # Horizontal line at y=0 from x_min to threshold
 873        x0c, y0c = self._d2c(self._x_min, 0.0)
 874        xtc_lo, ytc_lo = self._d2c(t, 0.0)
 875        c.create_line(x0c, y0c, xtc_lo, ytc_lo,
 876                      fill=CURVE_LINE, width=2)
 877
 878        # Vertical step at threshold
 879        xtc_hi, ytc_hi = self._d2c(t, 1.0)
 880        c.create_line(xtc_lo, ytc_lo, xtc_hi, ytc_hi,
 881                      fill=CURVE_LINE, width=2)
 882
 883        # Horizontal line at y=1 from threshold to x_max=1.0
 884        x1c, y1c = self._d2c(1.0, 1.0)
 885        c.create_line(xtc_hi, ytc_hi, x1c, y1c,
 886                      fill=CURVE_LINE, width=2)
 887
 888    def _draw_threshold_handle(self):
 889        """Draw a draggable vertical line and diamond handle at threshold."""
 890        if not self._action:
 891            return
 892        c = self._canvas
 893        t = self._action.threshold
 894
 895        # Vertical dashed guide line spanning the plot
 896        xtc, ytop = self._d2c(t, self._y_max)
 897        _, ybot = self._d2c(t, self._y_min)
 898        c.create_line(xtc, ytop, xtc, ybot,
 899                      fill=_THRESHOLD_LINE, width=1, dash=(6, 3))
 900
 901        # Diamond handle at (threshold, 0.5) — midpoint of the step
 902        _, yh = self._d2c(t, 0.5)
 903        r = _POINT_RADIUS + 1
 904        c.create_polygon(
 905            xtc, yh - r, xtc + r, yh, xtc, yh + r, xtc - r, yh,
 906            fill=_THRESHOLD_HANDLE, outline=_THRESHOLD_HANDLE_OUTLINE,
 907            width=2)
 908
 909    # --- Virtual Analog Mode ---
 910
 911    _VA_PRESS_COLOR = "#2060c0"    # Blue for press phase
 912    _VA_RELEASE_COLOR = "#c04020"  # Red-orange for release phase
 913    _VA_HANDLE_COLOR = "#40a040"   # Green for target/rest handles
 914    _VA_HANDLE_OUTLINE = "#207020"
 915    _VA_DIVIDER_COLOR = "#808080"
 916
 917    def _draw_va_ramp(self):
 918        """Draw the VA ramp simulation curve."""
 919        if not self._action:
 920            return
 921        from utils.input.virtual_analog import simulate_va_ramp
 922
 923        extra = self._action.extra
 924        neg_ramp = extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
 925        neg_accel = extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
 926        points = simulate_va_ramp(
 927            ramp_rate=float(extra.get(EXTRA_VA_RAMP_RATE, 0.0)),
 928            acceleration=float(extra.get(EXTRA_VA_ACCELERATION, 0.0)),
 929            negative_ramp_rate=float(neg_ramp) if neg_ramp is not None
 930            else None,
 931            negative_acceleration=float(neg_accel) if neg_accel is not None
 932            else None,
 933            zero_vel_on_release=bool(extra.get(
 934                EXTRA_VA_ZERO_VEL_ON_RELEASE, False)),
 935            target_value=float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0)),
 936            rest_value=float(extra.get(EXTRA_VA_REST_VALUE, 0.0)),
 937            total_duration=self._va_total_duration,
 938            press_duration=self._va_press_duration,
 939        )
 940
 941        c = self._canvas
 942        press_dur = self._va_press_duration
 943
 944        # Draw press/release divider
 945        dx, dy_top = self._d2c(press_dur, self._y_max)
 946        _, dy_bot = self._d2c(press_dur, self._y_min)
 947        c.create_line(dx, dy_top, dx, dy_bot,
 948                      fill=self._VA_DIVIDER_COLOR, width=1, dash=(6, 3))
 949
 950        # Labels for release/press regions (release drawn first)
 951        font_size = 7 if self._plot_w < 250 else 8
 952        mid_release = press_dur / 2
 953        mid_press = (press_dur + self._va_total_duration) / 2
 954        rx, _ = self._d2c(mid_release, self._y_max)
 955        px, _ = self._d2c(mid_press, self._y_max)
 956        c.create_text(rx, self._margin_y - 8, text="Release",
 957                      fill=self._VA_RELEASE_COLOR,
 958                      font=("TkDefaultFont", font_size))
 959        c.create_text(px, self._margin_y - 8, text="Press",
 960                      fill=self._VA_PRESS_COLOR,
 961                      font=("TkDefaultFont", font_size))
 962
 963        # Draw curve in two colors (release first, then press)
 964        release_coords = []
 965        press_coords = []
 966        for t, pos in points:
 967            cx, cy = self._d2c(t, pos)
 968            if t <= press_dur:
 969                release_coords.extend([cx, cy])
 970            else:
 971                if not press_coords and release_coords:
 972                    # Bridge point: add last release point to press
 973                    press_coords.extend(release_coords[-2:])
 974                press_coords.extend([cx, cy])
 975
 976        if len(release_coords) >= 4:
 977            c.create_line(*release_coords, fill=self._VA_RELEASE_COLOR,
 978                          width=2, smooth=False)
 979        if len(press_coords) >= 4:
 980            c.create_line(*press_coords, fill=self._VA_PRESS_COLOR,
 981                          width=2, smooth=False)
 982
 983    def _draw_va_handles(self):
 984        """Draw draggable horizontal handles for target and rest values."""
 985        if not self._action:
 986            return
 987        c = self._canvas
 988        extra = self._action.extra
 989        target = float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
 990        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
 991
 992        # Target handle: horizontal dashed line + diamond
 993        _, ty = self._d2c(0, target)
 994        c.create_line(self._margin_x, ty,
 995                      self._margin_x + self._plot_w, ty,
 996                      fill=self._VA_HANDLE_COLOR, width=1, dash=(4, 4))
 997        # Diamond at right edge
 998        hx = self._margin_x + self._plot_w - 15
 999        r = _POINT_RADIUS
1000        c.create_polygon(
1001            hx, ty - r, hx + r, ty, hx, ty + r, hx - r, ty,
1002            fill=self._VA_HANDLE_COLOR, outline=self._VA_HANDLE_OUTLINE,
1003            width=2)
1004        c.create_text(hx - r - 4, ty - r - 2, text="target",
1005                      fill=self._VA_HANDLE_COLOR, anchor=tk.E,
1006                      font=("TkDefaultFont", 7))
1007
1008        # Rest handle: horizontal dashed line + diamond
1009        _, ry = self._d2c(0, rest)
1010        c.create_line(self._margin_x, ry,
1011                      self._margin_x + self._plot_w, ry,
1012                      fill=self._VA_RELEASE_COLOR, width=1, dash=(4, 4))
1013        hx2 = self._margin_x + 15
1014        c.create_polygon(
1015            hx2, ry - r, hx2 + r, ry, hx2, ry + r, hx2 - r, ry,
1016            fill=self._VA_RELEASE_COLOR, outline="#901010",
1017            width=2)
1018        c.create_text(hx2 + r + 4, ry - r - 2, text="rest",
1019                      fill=self._VA_RELEASE_COLOR, anchor=tk.W,
1020                      font=("TkDefaultFont", 7))
1021
1022    def _draw_va_sim_trail(self):
1023        """Draw the live simulation trail with a dot at current position."""
1024        if not self._va_sim_trail:
1025            return
1026        c = self._canvas
1027        release_t = self._va_sim_release_time
1028
1029        # Build press and release coordinate lists
1030        press_coords = []
1031        release_coords = []
1032        for t, pos in self._va_sim_trail:
1033            cx, cy = self._d2c(t, pos)
1034            if release_t is None or t <= release_t:
1035                press_coords.extend([cx, cy])
1036            else:
1037                if not release_coords and press_coords:
1038                    release_coords.extend(press_coords[-2:])
1039                release_coords.extend([cx, cy])
1040
1041        if len(press_coords) >= 4:
1042            c.create_line(*press_coords, fill=self._VA_PRESS_COLOR,
1043                          width=2, smooth=False)
1044        if len(release_coords) >= 4:
1045            c.create_line(*release_coords, fill=self._VA_RELEASE_COLOR,
1046                          width=2, smooth=False)
1047
1048        # Draw dot at current position
1049        last_t, last_pos = self._va_sim_trail[-1]
1050        dx, dy = self._d2c(last_t, last_pos)
1051        r = self._VA_DOT_RADIUS
1052        color = (self._VA_PRESS_COLOR if self._va_sim_pressed
1053                 else self._VA_RELEASE_COLOR)
1054        c.create_oval(dx - r, dy - r, dx + r, dy + r,
1055                      fill=color, outline="white", width=1)
1056
1057        # Status text
1058        if self._va_sim_active:
1059            phase = "Press" if self._va_sim_pressed else "Release"
1060            self._status_var.set(
1061                f"{phase}  t={last_t:.2f}s  value={last_pos:.3f}")
1062
1063    # --- Spline Mode ---
1064
1065    def _draw_spline_curve(self):
1066        pts = self._points
1067        if len(pts) < 2:
1068            return
1069        s = self._display_scale
1070        n = _CURVE_SAMPLES_PER_SEG * (len(pts) - 1)
1071        x_min, x_max = pts[0]["x"], pts[-1]["x"]
1072        coords = []
1073        for i in range(n + 1):
1074            x = x_min + (x_max - x_min) * i / n
1075            y = evaluate_spline(pts, x) * s
1076            cx, cy = self._d2c(x, y)
1077            coords.extend([cx, cy])
1078        if len(coords) >= 4:
1079            self._canvas.create_line(
1080                *coords, fill=CURVE_LINE, width=2, smooth=False)
1081
1082    def _draw_handles(self):
1083        c = self._canvas
1084        s = self._display_scale
1085        for i, pt in enumerate(self._points):
1086            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1087            hdx, hdy = self._tangent_offset(pt["tangent"])
1088            c.create_line(cx - hdx, cy - hdy, cx + hdx, cy + hdy,
1089                          fill=HANDLE_LINE, width=1, dash=(4, 4))
1090            if i > 0:
1091                hx, hy = cx - hdx, cy - hdy
1092                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
1093                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
1094                              fill=HANDLE_FILL, outline="#308030")
1095            if i < len(self._points) - 1:
1096                hx, hy = cx + hdx, cy + hdy
1097                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
1098                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
1099                              fill=HANDLE_FILL, outline="#308030")
1100
1101    # --- Segment Mode ---
1102
1103    def _draw_segment_curve(self):
1104        pts = self._points
1105        if len(pts) < 2:
1106            return
1107        s = self._display_scale
1108        coords = []
1109        for pt in pts:
1110            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1111            coords.extend([cx, cy])
1112        if len(coords) >= 4:
1113            self._canvas.create_line(
1114                *coords, fill=CURVE_LINE, width=2, smooth=False)
1115
1116    # --- Points (shared by editable modes) ---
1117
1118    def _draw_points(self):
1119        c = self._canvas
1120        s = self._display_scale
1121        for i, pt in enumerate(self._points):
1122            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1123            is_endpoint = (i == 0 or i == len(self._points) - 1)
1124            is_mirror = (self._symmetric
1125                         and pt["x"] < -_MIN_X_GAP / 2)
1126            if is_mirror:
1127                fill = MIRROR_LINE
1128            elif is_endpoint:
1129                fill = ENDPOINT_FILL
1130            else:
1131                fill = POINT_FILL
1132            c.create_oval(cx - _POINT_RADIUS, cy - _POINT_RADIUS,
1133                          cx + _POINT_RADIUS, cy + _POINT_RADIUS,
1134                          fill=fill, outline=POINT_OUTLINE, width=2)
1135
1136    # ------------------------------------------------------------------
1137    # Curve Tracker (follow mouse along curve)
1138    # ------------------------------------------------------------------
1139
1140    def _evaluate_display_y(self, x: float) -> float | None:
1141        """Return the displayed Y value on the curve at data-x, or None."""
1142        if self._mode in ("raw", "scaled", "squared"):
1143            return self._compute_shaped_value(x)
1144        elif self._mode == "spline" and len(self._points) >= 2:
1145            return evaluate_spline(self._points, x) * self._display_scale
1146        elif self._mode == "segment" and len(self._points) >= 2:
1147            return evaluate_segments(self._points, x) * self._display_scale
1148        return None
1149
1150    def _on_mouse_move(self, event):
1151        """Draw a tracking dot that follows the curve under the cursor."""
1152        self._canvas.delete(_TRACKER_TAG)
1153        if self._mode is None or self._drag_type is not None:
1154            return
1155        x, _ = self._c2d(event.x, event.y)
1156        if x < self._x_min or x > 1.0:
1157            return
1158        y = self._evaluate_display_y(x)
1159        if y is None:
1160            return
1161        cx, cy = self._d2c(x, y)
1162        r = _TRACKER_RADIUS
1163        self._canvas.create_oval(
1164            cx - r, cy - r, cx + r, cy + r,
1165            fill=_TRACKER_FILL, outline="", tags=_TRACKER_TAG)
1166        # Label with X,Y offset to upper-right; flip if near edge
1167        label = f"({x:.2f}, {y:.2f})"
1168        lx = cx + 10 if cx < self._margin_x + self._plot_w - 80 else cx - 10
1169        ly = cy - 14 if cy > self._margin_y + 14 else cy + 14
1170        anchor = tk.SW if lx > cx else tk.SE
1171        if ly > cy:
1172            anchor = tk.NW if lx > cx else tk.NE
1173        self._canvas.create_text(
1174            lx, ly, text=label, fill=_TRACKER_FILL,
1175            font=("TkDefaultFont", 7), anchor=anchor,
1176            tags=_TRACKER_TAG)
1177
1178    def _on_mouse_leave(self, event):
1179        """Remove tracker when mouse leaves the canvas."""
1180        self._canvas.delete(_TRACKER_TAG)
1181
1182    # ------------------------------------------------------------------
1183    # Hit Testing
1184    # ------------------------------------------------------------------
1185
1186    def _hit_test(self, cx, cy):
1187        """Find element at canvas position.
1188
1189        Returns (type, idx, side) or None.
1190        """
1191        s = self._display_scale
1192        if self._mode == "spline":
1193            # Check handles first
1194            for i, pt in enumerate(self._points):
1195                if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
1196                    continue
1197                px, py = self._d2c(pt["x"], pt["y"] * s)
1198                hdx, hdy = self._tangent_offset(pt["tangent"])
1199                if i < len(self._points) - 1:
1200                    if math.hypot(cx - (px + hdx),
1201                                  cy - (py + hdy)) <= _HANDLE_RADIUS + 3:
1202                        return ("handle", i, "out")
1203                if i > 0:
1204                    if math.hypot(cx - (px - hdx),
1205                                  cy - (py - hdy)) <= _HANDLE_RADIUS + 3:
1206                        return ("handle", i, "in")
1207
1208        if self._mode in ("spline", "segment"):
1209            for i, pt in enumerate(self._points):
1210                if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
1211                    continue
1212                px, py = self._d2c(pt["x"], pt["y"] * s)
1213                if math.hypot(cx - px, cy - py) <= _POINT_RADIUS + 3:
1214                    return ("point", i, None)
1215
1216        if self._mode in ("scaled", "squared"):
1217            # Check scale handle
1218            y_val = self._compute_shaped_value(1.0)
1219            hcx, hcy = self._d2c(1.0, y_val)
1220            if math.hypot(cx - hcx, cy - hcy) <= _POINT_RADIUS + 5:
1221                return ("scale_handle", 0, None)
1222
1223        if self._mode == "threshold" and self._action:
1224            # Check threshold handle at (threshold, 0.5)
1225            t = self._action.threshold
1226            hcx, hcy = self._d2c(t, 0.5)
1227            if math.hypot(cx - hcx, cy - hcy) <= _POINT_RADIUS + 5:
1228                return ("threshold_handle", 0, None)
1229
1230        if self._mode == "virtual_analog" and self._action:
1231            extra = self._action.extra
1232            target = extra.get(EXTRA_VA_TARGET_VALUE, 1.0)
1233            rest = extra.get(EXTRA_VA_REST_VALUE, 0.0)
1234            # Target handle (right side diamond)
1235            hx = self._margin_x + self._plot_w - 15
1236            _, ty = self._d2c(0, target)
1237            if math.hypot(cx - hx, cy - ty) <= _POINT_RADIUS + 5:
1238                return ("va_target_handle", 0, None)
1239            # Rest handle (left side diamond)
1240            hx2 = self._margin_x + 15
1241            _, ry = self._d2c(0, rest)
1242            if math.hypot(cx - hx2, cy - ry) <= _POINT_RADIUS + 5:
1243                return ("va_rest_handle", 0, None)
1244
1245        return None
1246
1247    # ------------------------------------------------------------------
1248    # Mouse Interaction
1249    # ------------------------------------------------------------------
1250
1251    def _on_press(self, event):
1252        if self._mode is None:
1253            return
1254
1255        hit = self._hit_test(event.x, event.y)
1256        if hit:
1257            self._drag_type, self._drag_idx, self._drag_side = hit
1258            self._drag_undo_pushed = False
1259        else:
1260            self._drag_type = None
1261            if self._mode in ("spline", "segment"):
1262                self._add_point_at(event.x, event.y)
1263
1264    def _on_drag(self, event):
1265        if self._drag_type is None:
1266            return
1267
1268        if self._drag_type == "scale_handle":
1269            self._drag_scale_handle(event)
1270            return
1271
1272        if self._drag_type == "threshold_handle":
1273            self._drag_threshold_handle(event)
1274            return
1275
1276        if self._drag_type in ("va_target_handle", "va_rest_handle"):
1277            self._drag_va_handle(event)
1278            return
1279
1280        if self._mode not in ("spline", "segment"):
1281            return
1282
1283        if not self._drag_undo_pushed:
1284            self._push_undo()
1285            self._drag_undo_pushed = True
1286
1287        i = self._drag_idx
1288        pt = self._points[i]
1289
1290        s = self._display_scale
1291
1292        if self._drag_type == "point":
1293            _, vis_y = self._c2d(event.x, event.y)
1294            # Un-scale cursor Y to get raw point Y
1295            y = vis_y / s if abs(s) > 1e-6 else vis_y
1296            is_endpoint = (i == 0 or i == len(self._points) - 1)
1297
1298            # Center point with symmetry: y locked to 0
1299            if self._symmetric and abs(pt["x"]) < _MIN_X_GAP / 2:
1300                pt["y"] = 0.0
1301            else:
1302                y = max(-1.0, min(1.0, y))
1303                if self._mode == "segment" and self._monotonic:
1304                    y = self._clamp_monotonic(i, y)
1305                pt["y"] = round(y, 3)
1306
1307            # Intermediate points: also move X
1308            if not is_endpoint:
1309                x, _ = self._c2d(event.x, event.y)
1310                x_lo = self._points[i - 1]["x"] + _MIN_X_GAP
1311                x_hi = self._points[i + 1]["x"] - _MIN_X_GAP
1312                if self._symmetric and pt["x"] > 0:
1313                    x_lo = max(x_lo, _MIN_X_GAP)
1314                pt["x"] = round(max(x_lo, min(x_hi, x)), 3)
1315
1316                if self._mode == "segment" and self._monotonic:
1317                    pt["y"] = round(
1318                        self._clamp_monotonic(i, pt["y"]), 3)
1319
1320        elif self._drag_type == "handle" and self._mode == "spline":
1321            cx, cy = self._d2c(pt["x"], pt["y"] * s)
1322            dx, dy = event.x - cx, event.y - cy
1323            if self._drag_side == "in":
1324                dx, dy = -dx, -dy
1325            if math.hypot(dx, dy) > 5:
1326                t = self._offset_to_tangent(dx, dy)
1327                pt["tangent"] = round(max(-10.0, min(10.0, t)), 3)
1328
1329        if self._symmetric:
1330            self._enforce_symmetry()
1331            for j, p in enumerate(self._points):
1332                if p is pt:
1333                    self._drag_idx = j
1334                    break
1335
1336        info = f"x={pt['x']:.2f}  y={pt['y']:.3f}"
1337        if self._mode == "spline":
1338            info += f"  tangent={pt.get('tangent', 0):.3f}"
1339        self._status_var.set(f"Point {self._drag_idx}: {info}")
1340        self._draw()
1341
1342    def _drag_scale_handle(self, event):
1343        """Drag the scale handle to adjust action.scale.
1344
1345        Uses pixel-delta from drag start for consistent sensitivity
1346        regardless of current Y range or scale magnitude.
1347        """
1348        if not self._action:
1349            return
1350        if not self._drag_undo_pushed:
1351            if self._on_before_change:
1352                self._on_before_change(200)
1353            self._drag_undo_pushed = True
1354            self._drag_start_y = event.y
1355            self._drag_start_scale = self._action.scale
1356
1357        if self._plot_h <= 0:
1358            return
1359
1360        # Compute unscaled base output at x=1.0
1361        old_scale = self._action.scale
1362        self._action.scale = 1.0
1363        base = self._compute_shaped_value(1.0)
1364        self._action.scale = old_scale
1365
1366        # Pixel delta (positive = dragged up = increase if base > 0)
1367        delta_px = self._drag_start_y - event.y
1368        # Fixed rate: one full plot height = 2.0 scale units
1369        scale_per_px = 2.0 / self._plot_h
1370        # Match drag direction to curve direction
1371        direction = 1.0 if base >= 0 else -1.0
1372        new_scale = self._drag_start_scale + delta_px * scale_per_px * direction
1373
1374        # Clamp scale: tighter when wide range is off
1375        if self._wide_range_var.get():
1376            new_scale = max(-10.0, min(10.0, new_scale))
1377        else:
1378            # Clamp so max output stays in [-1, 1]
1379            if abs(base) > 0.01:
1380                max_scale = 1.0 / abs(base)
1381                new_scale = max(-max_scale, min(max_scale, new_scale))
1382            else:
1383                new_scale = max(-1.0, min(1.0, new_scale))
1384
1385        new_scale = round(new_scale, 2)
1386        self._action.scale = new_scale
1387        self._status_var.set(f"Scale: {new_scale:.2f}")
1388        self._draw()
1389
1390        if self._on_curve_changed:
1391            self._on_curve_changed()
1392
1393    def _drag_threshold_handle(self, event):
1394        """Drag the threshold handle to adjust action.threshold."""
1395        if not self._action:
1396            return
1397        if not self._drag_undo_pushed:
1398            if self._on_before_change:
1399                self._on_before_change(200)
1400            self._drag_undo_pushed = True
1401
1402        # Convert pixel X to data X, clamp to 0..1
1403        x, _ = self._c2d(event.x, event.y)
1404        new_threshold = round(max(0.0, min(1.0, x)), 2)
1405        self._action.threshold = new_threshold
1406        self._status_var.set(f"Threshold: {new_threshold:.2f}")
1407        self._draw()
1408
1409        if self._on_curve_changed:
1410            self._on_curve_changed()
1411
1412    def _drag_va_handle(self, event):
1413        """Drag VA target or rest handle to adjust values."""
1414        if not self._action:
1415            return
1416        if not self._drag_undo_pushed:
1417            if self._on_before_change:
1418                self._on_before_change(200)
1419            self._drag_undo_pushed = True
1420
1421        _, y = self._c2d(event.x, event.y)
1422        y = round(max(-10.0, min(10.0, y)), 2)
1423
1424        if self._drag_type == "va_target_handle":
1425            self._action.extra[EXTRA_VA_TARGET_VALUE] = y
1426            self._status_var.set(f"Target: {y:.2f}")
1427        else:
1428            self._action.extra[EXTRA_VA_REST_VALUE] = y
1429            self._status_var.set(f"Rest: {y:.2f}")
1430        self._draw()
1431
1432        if self._on_curve_changed:
1433            self._on_curve_changed()
1434
1435    # ------------------------------------------------------------------
1436    # VA Live Simulation
1437    # ------------------------------------------------------------------
1438
1439    _VA_SIM_DT = 0.02       # 50 Hz physics step
1440    _VA_SIM_INTERVAL = 20   # ms between animation frames
1441    _VA_SIM_TIMEOUT = 10.0  # Max sim duration (seconds)
1442    _VA_DOT_RADIUS = 5
1443
1444    def _on_va_sim_press(self, event):
1445        """Start or continue live simulation when button is pressed."""
1446        if not self._action or self._mode != "virtual_analog":
1447            return
1448        is_toggle = (self._action.extra.get(
1449            EXTRA_VA_BUTTON_MODE, "held") == "toggle")
1450        if is_toggle:
1451            self._va_sim_pressed = not self._va_sim_pressed
1452        else:
1453            self._va_sim_pressed = True
1454        if self._va_sim_active:
1455            # Sim already running — continue trail
1456            return
1457        if self._va_sim_trail:
1458            # Previous sim finished — continue from where it left off
1459            self._va_sim_active = True
1460            self._va_sim_tick()
1461            return
1462        # Fresh start
1463        extra = self._action.extra
1464        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
1465        self._va_sim_position = rest
1466        self._va_sim_velocity = 0.0
1467        self._va_sim_time = 0.0
1468        self._va_sim_trail = [(0.0, rest)]
1469        self._va_sim_active = True
1470        self._va_sim_was_pressed = False
1471        self._va_sim_release_time = None
1472        self._va_sim_tick()
1473
1474    def _on_va_sim_release(self, event):
1475        """Mark button released — sim continues for release phase.
1476
1477        In toggle mode, release is a no-op (state toggled on press).
1478        """
1479        if (self._action and self._action.extra.get(
1480                EXTRA_VA_BUTTON_MODE, "held") == "toggle"):
1481            return
1482        self._va_sim_pressed = False
1483
1484    def _va_sim_tick(self):
1485        """Run one physics step and schedule the next frame."""
1486        if not self._va_sim_active or not self._action:
1487            return
1488
1489        extra = self._action.extra
1490        dt = self._VA_SIM_DT
1491        pressed = self._va_sim_pressed
1492
1493        ramp_rate = float(extra.get(EXTRA_VA_RAMP_RATE, 0.0))
1494        acceleration = float(extra.get(EXTRA_VA_ACCELERATION, 0.0))
1495        neg_ramp = extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
1496        neg_ramp = float(neg_ramp) if neg_ramp is not None else ramp_rate
1497        neg_accel = extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
1498        neg_accel = float(neg_accel) if neg_accel is not None else acceleration
1499        zero_vel = bool(extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False))
1500        target = float(extra.get(EXTRA_VA_TARGET_VALUE, 1.0))
1501        rest = float(extra.get(EXTRA_VA_REST_VALUE, 0.0))
1502        min_val = min(rest, target)
1503        max_val = max(rest, target)
1504
1505        # Release edge detection
1506        if self._va_sim_was_pressed and not pressed:
1507            if zero_vel:
1508                self._va_sim_velocity = 0.0
1509            if self._va_sim_release_time is None:
1510                self._va_sim_release_time = self._va_sim_time
1511        self._va_sim_was_pressed = pressed
1512
1513        self._va_sim_time += dt
1514
1515        # Physics step
1516        if pressed:
1517            goal = target
1518            max_spd = ramp_rate
1519            acc = acceleration
1520        else:
1521            goal = rest
1522            max_spd = neg_ramp
1523            acc = neg_accel
1524
1525        # Physics step — modes are mutually exclusive:
1526        #   max_spd > 0: constant velocity (triangle wave)
1527        #   acc > 0:     v = u + a*t, no cap (hyperbolic curve)
1528        #   both 0:      instant jump
1529        diff = goal - self._va_sim_position
1530        if abs(diff) < 1e-9:
1531            self._va_sim_position = goal
1532            self._va_sim_velocity = 0.0
1533        elif max_spd == 0.0 and acc == 0.0:
1534            self._va_sim_position = goal
1535            self._va_sim_velocity = 0.0
1536        else:
1537            direction = 1.0 if diff > 0 else -1.0
1538            if max_spd > 0.0:
1539                self._va_sim_velocity = direction * max_spd
1540            else:
1541                self._va_sim_velocity += direction * acc * dt
1542            self._va_sim_position += self._va_sim_velocity * dt
1543            new_diff = goal - self._va_sim_position
1544            if (direction > 0 and new_diff < 0) or \
1545               (direction < 0 and new_diff > 0):
1546                self._va_sim_position = goal
1547                self._va_sim_velocity = 0.0
1548
1549        clamped = max(min_val, min(max_val, self._va_sim_position))
1550        if clamped != self._va_sim_position:
1551            self._va_sim_velocity = 0.0
1552            self._va_sim_position = clamped
1553        self._va_sim_trail.append(
1554            (self._va_sim_time, self._va_sim_position))
1555
1556        # Auto-extend X axis
1557        if self._va_sim_time > self._x_max - 0.2:
1558            self._x_max = self._va_sim_time + 1.0
1559
1560        self._draw()
1561
1562        # Stop condition: timeout only (sim keeps running to show off-time)
1563        if self._va_sim_time >= self._VA_SIM_TIMEOUT:
1564            self._va_sim_active = False
1565            self._status_var.set(
1566                "Simulation timed out — click Reset to start over")
1567            return
1568
1569        self._va_sim_after_id = self.after(
1570            self._VA_SIM_INTERVAL, self._va_sim_tick)
1571
1572    def _on_va_sim_reset(self):
1573        """Reset the simulation — clear trail and restore default view."""
1574        self._va_sim_stop()
1575        self._draw()
1576
1577    def _va_sim_stop(self):
1578        """Cancel any running VA simulation."""
1579        if self._va_sim_after_id is not None:
1580            self.after_cancel(self._va_sim_after_id)
1581            self._va_sim_after_id = None
1582        self._va_sim_active = False
1583        self._va_sim_pressed = False
1584        self._va_sim_trail = []
1585        self._va_sim_release_time = None
1586        if self._mode == "virtual_analog":
1587            self._x_max = self._va_total_duration
1588
1589    def _on_release(self, event):
1590        if self._drag_type in ("point", "handle") and self._drag_undo_pushed:
1591            self._save_to_action()
1592        self._drag_type = None
1593        if self._mode in ("spline", "segment"):
1594            self._status_var.set(
1595                "Click to add | Right-click to remove | Drag to adjust")
1596        elif self._mode in ("scaled", "squared"):
1597            self._status_var.set("Drag handle to adjust scale")
1598        elif self._mode == "threshold":
1599            self._status_var.set("Drag handle to adjust threshold")
1600        elif self._mode == "virtual_analog":
1601            self._status_var.set(
1602                "Drag handles to adjust target/rest values")
1603        elif self._mode == "raw":
1604            self._status_var.set("Read-only: raw input (no shaping)")
1605
1606    def _on_right_click(self, event):
1607        if self._mode not in ("spline", "segment"):
1608            return
1609        hit = self._hit_test(event.x, event.y)
1610        if not hit or hit[0] != "point":
1611            return
1612        i = hit[1]
1613        if i == 0 or i == len(self._points) - 1:
1614            self._status_var.set("Cannot remove endpoints")
1615            return
1616        if len(self._points) <= 2:
1617            self._status_var.set("Need at least 2 points")
1618            return
1619        self._drag_type = None
1620        self._remove_point(i)
1621
1622    # ------------------------------------------------------------------
1623    # Point Add / Remove
1624    # ------------------------------------------------------------------
1625
1626    def _add_point_at(self, cx, cy):
1627        x, vis_y = self._c2d(cx, cy)
1628        s = self._display_scale
1629        # Un-scale cursor Y to get raw point Y
1630        y = vis_y / s if abs(s) > 1e-6 else vis_y
1631
1632        if self._symmetric and x < -_MIN_X_GAP / 2:
1633            self._status_var.set(
1634                "Add points on the positive side (symmetry)")
1635            return
1636
1637        x_min = self._points[0]["x"]
1638        x_max = self._points[-1]["x"]
1639        if x <= x_min + _MIN_X_GAP or x >= x_max - _MIN_X_GAP:
1640            return
1641        y = max(-1.0, min(1.0, y))
1642
1643        for pt in self._points:
1644            if abs(pt["x"] - x) < _MIN_X_GAP:
1645                return
1646
1647        if self._mode == "segment" and self._monotonic:
1648            y = self._clamp_monotonic_insert(x, y)
1649
1650        self._push_undo()
1651        new_pt = {"x": round(x, 3), "y": round(y, 3)}
1652        if self._mode == "spline":
1653            new_pt["tangent"] = round(numerical_slope(self._points, x), 3)
1654        self._points.append(new_pt)
1655        self._points.sort(key=lambda p: p["x"])
1656
1657        if self._symmetric:
1658            self._enforce_symmetry()
1659
1660        self._save_to_action()
1661        self._draw()
1662        self._status_var.set(
1663            f"Added point at x={x:.2f} ({len(self._points)} points)")
1664
1665    def _remove_point(self, idx):
1666        if idx == 0 or idx == len(self._points) - 1:
1667            return
1668        if len(self._points) <= 2:
1669            return
1670        self._push_undo()
1671        self._points.pop(idx)
1672        if self._symmetric:
1673            self._enforce_symmetry()
1674        self._save_to_action()
1675        self._draw()
1676        self._status_var.set(
1677            f"Removed point ({len(self._points)} points)")
1678
1679    # ------------------------------------------------------------------
1680    # Monotonic Constraint (segment mode)
1681    # ------------------------------------------------------------------
1682
1683    def _clamp_monotonic(self, idx: int, y: float) -> float:
1684        if idx > 0:
1685            y = max(y, self._points[idx - 1]["y"])
1686        if idx < len(self._points) - 1:
1687            y = min(y, self._points[idx + 1]["y"])
1688        return y
1689
1690    def _clamp_monotonic_insert(self, x: float, y: float) -> float:
1691        lo_y = -1.0
1692        hi_y = 1.0
1693        for pt in self._points:
1694            if pt["x"] < x:
1695                lo_y = max(lo_y, pt["y"])
1696            elif pt["x"] > x:
1697                hi_y = min(hi_y, pt["y"])
1698                break
1699        return max(lo_y, min(hi_y, y))
1700
1701    def _enforce_monotonic(self):
1702        for i in range(1, len(self._points)):
1703            if self._points[i]["y"] < self._points[i - 1]["y"]:
1704                self._points[i]["y"] = self._points[i - 1]["y"]
1705
1706    # ------------------------------------------------------------------
1707    # Symmetry
1708    # ------------------------------------------------------------------
1709
1710    def _on_wide_range_toggle(self):
1711        """Toggle wide range mode for scaled/squared visualization."""
1712        self._draw()
1713
1714    def _on_processed_toggle(self):
1715        """Toggle display of scale and inversion on the curve."""
1716        self._draw()
1717
1718    def _on_symmetry_toggle(self):
1719        self._push_undo()
1720        self._symmetric = self._sym_var.get()
1721        if self._symmetric:
1722            self._enforce_symmetry()
1723            self._save_to_action()
1724            self._draw()
1725            self._status_var.set("Symmetry on — edit positive side")
1726
1727    def _enforce_symmetry(self):
1728        positive = [pt for pt in self._points
1729                    if pt["x"] > _MIN_X_GAP / 2]
1730        center = None
1731        for pt in self._points:
1732            if abs(pt["x"]) < _MIN_X_GAP / 2:
1733                center = pt
1734                break
1735
1736        is_spline = self._mode == "spline"
1737        if center is None:
1738            center = {"x": 0.0, "y": 0.0}
1739            if is_spline:
1740                center["tangent"] = 1.0
1741        else:
1742            center["x"] = 0.0
1743            center["y"] = 0.0
1744
1745        new_points = []
1746        for pt in reversed(positive):
1747            mirror = {"x": round(-pt["x"], 3), "y": round(-pt["y"], 3)}
1748            if is_spline:
1749                mirror["tangent"] = pt["tangent"]
1750            new_points.append(mirror)
1751        new_points.append(center)
1752        new_points.extend(positive)
1753        self._points = new_points
1754
1755    # ------------------------------------------------------------------
1756    # Undo
1757    # ------------------------------------------------------------------
1758
1759    def _push_undo(self):
1760        self._undo_stack.push(self._points)
1761
1762    def _on_ctrl_z(self, event):
1763        """Handle Ctrl+Z within the curve editor."""
1764        self._pop_undo()
1765        return "break"  # Prevent app-level bind_all undo from firing
1766
1767    def _pop_undo(self):
1768        state = self._undo_stack.pop()
1769        if state is None:
1770            self._status_var.set("Nothing to undo")
1771            return
1772        self._points = state
1773        self._save_to_action(push_app_undo=False)
1774        self._draw()
1775        self._status_var.set(f"Undo ({len(self._undo_stack)} remaining)")
1776
1777    # ------------------------------------------------------------------
1778    # Data Sync
1779    # ------------------------------------------------------------------
1780
1781    def _save_to_action(self, push_app_undo=True):
1782        """Write points back to the action and notify.
1783
1784        Args:
1785            push_app_undo: if True, push an undo snapshot to the app
1786                before saving. Set to False when called from _pop_undo
1787                so the curve editor's undo doesn't create a new app-level
1788                undo entry.
1789        """
1790        if not self._action:
1791            return
1792        if push_app_undo and self._on_before_change:
1793            self._on_before_change(200)
1794        if self._mode == "spline":
1795            self._action.extra[EXTRA_SPLINE_POINTS] = deepcopy(self._points)
1796        elif self._mode == "segment":
1797            self._action.extra[EXTRA_SEGMENT_POINTS] = deepcopy(self._points)
1798        if self._on_curve_changed:
1799            self._on_curve_changed()
1800
1801    # ------------------------------------------------------------------
1802    # Reset
1803    # ------------------------------------------------------------------
1804
1805    def _on_reset(self):
1806        if self._mode not in ("spline", "segment"):
1807            return
1808        self._push_undo()
1809        if self._mode == "spline":
1810            self._points = default_spline_points()
1811        else:
1812            self._points = default_segment_points()
1813        if self._symmetric:
1814            self._enforce_symmetry()
1815        if self._mode == "segment" and self._monotonic:
1816            self._enforce_monotonic()
1817        self._save_to_action()
1818        self._draw()
1819        self._status_var.set("Reset to linear")
1820
1821    def _on_monotonic_toggle(self):
1822        self._push_undo()
1823        self._monotonic = self._mono_var.get()
1824        if self._monotonic:
1825            self._enforce_monotonic()
1826            self._save_to_action()
1827            self._draw()
1828            self._status_var.set(
1829                "Monotonic on — output increases with input")
1830
1831    # ------------------------------------------------------------------
1832    # Import / Export / Copy
1833    # ------------------------------------------------------------------
1834
1835    def _on_export(self):
1836        if self._mode not in ("spline", "segment"):
1837            return
1838        curve_type = "spline" if self._mode == "spline" else "segment"
1839        path = filedialog.asksaveasfilename(
1840            parent=self.winfo_toplevel(),
1841            title=f"Export {curve_type.title()} Curve",
1842            defaultextension=".yaml",
1843            filetypes=[("YAML files", "*.yaml *.yml"),
1844                       ("All files", "*.*")])
1845        if not path:
1846            return
1847        data = {"type": curve_type, "points": deepcopy(self._points)}
1848        with open(path, "w") as f:
1849            yaml.dump(data, f, default_flow_style=False, sort_keys=False)
1850        self._status_var.set(f"Exported to {path}")
1851
1852    def _on_import(self):
1853        if self._mode not in ("spline", "segment"):
1854            return
1855        path = filedialog.askopenfilename(
1856            parent=self.winfo_toplevel(),
1857            title="Import Curve",
1858            filetypes=[("YAML files", "*.yaml *.yml"),
1859                       ("All files", "*.*")])
1860        if not path:
1861            return
1862        try:
1863            with open(path) as f:
1864                data = yaml.safe_load(f)
1865        except Exception as exc:
1866            messagebox.showerror("Import Failed",
1867                                 f"Could not read YAML file:\n{exc}",
1868                                 parent=self.winfo_toplevel())
1869            return
1870
1871        if isinstance(data, dict):
1872            points = data.get("points", [])
1873        elif isinstance(data, list):
1874            points = data
1875        else:
1876            messagebox.showerror(
1877                "Import Failed",
1878                "File does not contain curve data.",
1879                parent=self.winfo_toplevel())
1880            return
1881
1882        if not points or not isinstance(points, list) or not all(
1883                isinstance(p, dict) and "x" in p and "y" in p
1884                for p in points):
1885            messagebox.showerror(
1886                "Import Failed",
1887                "Invalid point data. Each point must have 'x' and 'y'.",
1888                parent=self.winfo_toplevel())
1889            return
1890
1891        self._push_undo()
1892        if self._mode == "spline":
1893            for p in points:
1894                if "tangent" not in p:
1895                    p["tangent"] = 1.0
1896            self._points = [dict(p) for p in points]
1897        else:
1898            self._points = [{"x": p["x"], "y": p["y"]} for p in points]
1899        self._points.sort(key=lambda p: p["x"])
1900        self._save_to_action()
1901        self._draw()
1902        self._status_var.set(f"Imported from {path}")
1903
1904    def _on_copy_from(self):
1905        if self._mode not in ("spline", "segment"):
1906            return
1907        curves = {}
1908        if self._get_other_curves:
1909            curves = self._get_other_curves(self._mode)
1910        if not curves:
1911            self._status_var.set("No other curves available")
1912            return
1913
1914        win = tk.Toplevel(self.winfo_toplevel())
1915        win.title("Copy Curve From...")
1916        win.transient(self.winfo_toplevel())
1917        win.grab_set()
1918        win.resizable(False, False)
1919
1920        ttk.Label(win, text="Select an action to copy its curve:",
1921                  padding=5).pack(anchor=tk.W)
1922        listbox = tk.Listbox(win, height=min(10, len(curves)), width=40)
1923        listbox.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
1924        names = sorted(curves.keys())
1925        for name in names:
1926            listbox.insert(tk.END, name)
1927
1928        def on_ok():
1929            sel = listbox.curselection()
1930            if not sel:
1931                return
1932            chosen = names[sel[0]]
1933            pts = curves[chosen]
1934            self._push_undo()
1935            if self._mode == "spline":
1936                for p in pts:
1937                    if "tangent" not in p:
1938                        p["tangent"] = 1.0
1939                self._points = [dict(p) for p in pts]
1940            else:
1941                self._points = [{"x": p["x"], "y": p["y"]} for p in pts]
1942            self._points.sort(key=lambda p: p["x"])
1943            self._save_to_action()
1944            self._draw()
1945            self._status_var.set(f"Copied curve from {chosen}")
1946            win.destroy()
1947
1948        listbox.bind("<Double-1>", lambda e: on_ok())
1949        bf = ttk.Frame(win)
1950        bf.pack(fill=tk.X, padx=10, pady=(0, 10))
1951        ttk.Button(bf, text="OK", command=on_ok).pack(
1952            side=tk.RIGHT, padx=5)
1953        ttk.Button(bf, text="Cancel",
1954                   command=win.destroy).pack(side=tk.RIGHT)
1955
1956        win.update_idletasks()
1957        px = self.winfo_toplevel().winfo_rootx()
1958        py = self.winfo_toplevel().winfo_rooty()
1959        pw = self.winfo_toplevel().winfo_width()
1960        ph = self.winfo_toplevel().winfo_height()
1961        ww, wh = win.winfo_width(), win.winfo_height()
1962        win.geometry(f"+{px + (pw - ww) // 2}+{py + (ph - wh) // 2}")

Embeddable curve editor supporting spline, segment, and visualization modes.

Replaces the Phase 2 placeholder in the lower-left pane of the Action Editor tab.

CurveEditorWidget( parent, *, on_before_change=None, on_curve_changed=None, get_other_curves=None, get_advanced_flags=None)
 97    def __init__(self, parent, *,
 98                 on_before_change=None,
 99                 on_curve_changed=None,
100                 get_other_curves=None,
101                 get_advanced_flags=None):
102        super().__init__(parent)
103
104        self._on_before_change = on_before_change
105        self._on_curve_changed = on_curve_changed
106        self._get_other_curves = get_other_curves
107        self._get_advanced_flags = get_advanced_flags or (
108            lambda: {"splines": True, "nonmono": True})
109
110        self._action: ActionDefinition | None = None
111        self._qname: str | None = None
112        self._mode: str | None = None  # spline/segment/raw/scaled/squared
113        self._points: list[dict] = []
114
115        # Drag state
116        self._drag_type = None   # "point", "handle", or "scale_handle"
117        self._drag_idx = None
118        self._drag_side = None   # "in" or "out" (spline handles)
119        self._drag_undo_pushed = False
120
121        # Options
122        self._symmetric = False
123        self._monotonic = True
124
125        # Virtual Analog state
126        self._va_press_duration = 1.5
127        self._va_total_duration = 3.0
128
129        # VA live simulation state
130        self._va_sim_active = False
131        self._va_sim_pressed = False
132        self._va_sim_position = 0.0
133        self._va_sim_velocity = 0.0
134        self._va_sim_time = 0.0
135        self._va_sim_trail = []
136        self._va_sim_after_id = None
137        self._va_sim_was_pressed = False
138        self._va_sim_release_time = None
139
140        # Undo stack (max 30)
141        self._undo_stack = UndoStack()
142
143        # Canvas sizing (computed on configure)
144        self._margin_x = 35
145        self._margin_y = 35
146        self._plot_w = 0
147        self._plot_h = 0
148
149        # X-axis range: -1..1 for sticks, 0..1 for triggers, 0..N for VA
150        self._x_min = -1.0
151        self._x_max = 1.0
152
153        # Y-axis range: defaults to (-1, 1), auto-scaled for visualization
154        self._y_min = -1.0
155        self._y_max = 1.0
156
157        self._build_ui()

Construct a Ttk Frame with parent master.

STANDARD OPTIONS

class, cursor, style, takefocus

WIDGET-SPECIFIC OPTIONS

borderwidth, relief, padding, width, height
def on_advanced_changed(self):
389    def on_advanced_changed(self):
390        """Refresh UI elements affected by Advanced menu toggles."""
391        if self._mode in ("spline", "segment"):
392            self._update_toolbar()

Refresh UI elements affected by Advanced menu toggles.

def load_action( self, action: utils.controller.ActionDefinition, qname: str, bound_inputs: list[str] | None = None):
394    def load_action(self, action: ActionDefinition, qname: str,
395                    bound_inputs: list[str] | None = None):
396        """Populate the widget from the given action.
397
398        Args:
399            bound_inputs: list of input names bound to this action.
400                If all are trigger inputs (0..1 range), the X axis
401                adjusts from -1..1 to 0..1.
402        """
403        self._va_sim_stop()
404        self._action = action
405        self._qname = qname
406        self._undo_stack.clear()
407        self._drag_type = None
408        self._symmetric = False
409        self._sym_var.set(False)
410        self._monotonic = True
411        self._mono_var.set(True)
412
413        # Determine mode from input_type + trigger_mode
414        if action.input_type == InputType.VIRTUAL_ANALOG:
415            self._mode = "virtual_analog"
416        elif action.input_type == InputType.BOOLEAN_TRIGGER:
417            self._mode = "threshold"
418        elif action.input_type != InputType.ANALOG:
419            self._mode = None
420        elif action.trigger_mode == EventTriggerMode.SPLINE:
421            self._mode = "spline"
422        elif action.trigger_mode == EventTriggerMode.SEGMENTED:
423            self._mode = "segment"
424        elif action.trigger_mode == EventTriggerMode.RAW:
425            self._mode = "raw"
426        elif action.trigger_mode == EventTriggerMode.SCALED:
427            self._mode = "scaled"
428        elif action.trigger_mode == EventTriggerMode.SQUARED:
429            self._mode = "squared"
430        else:
431            self._mode = None
432
433        # Set X range (must be after mode is determined)
434        self._update_x_range(bound_inputs)
435
436        # Load points for editable modes
437        if self._mode == "spline":
438            pts = action.extra.get(EXTRA_SPLINE_POINTS)
439            if not pts:
440                pts = default_spline_points()
441                action.extra[EXTRA_SPLINE_POINTS] = pts
442            self._points = [dict(p) for p in pts]
443            self._points.sort(key=lambda p: p["x"])
444        elif self._mode == "segment":
445            pts = action.extra.get(EXTRA_SEGMENT_POINTS)
446            if not pts:
447                pts = default_segment_points()
448                action.extra[EXTRA_SEGMENT_POINTS] = pts
449            self._points = [{"x": p["x"], "y": p["y"]} for p in pts]
450            self._points.sort(key=lambda p: p["x"])
451        else:
452            self._points = []
453
454        self._update_toolbar()
455        self._update_canvas_bg()
456        self._draw()

Populate the widget from the given action.

Args: bound_inputs: list of input names bound to this action. If all are trigger inputs (0..1 range), the X axis adjusts from -1..1 to 0..1.

def clear(self):
458    def clear(self):
459        """Clear to inactive state."""
460        self._va_sim_stop()
461        self._action = None
462        self._qname = None
463        self._mode = None
464        self._points = []
465        self._undo_stack.clear()
466        self._drag_type = None
467        self._update_toolbar()
468        self._update_canvas_bg()
469        self._draw()

Clear to inactive state.

def get_mode(self) -> str | None:
471    def get_mode(self) -> str | None:
472        return self._mode
def refresh(self):
474    def refresh(self):
475        """Redraw from current action (call after external parameter change)."""
476        if self._action:
477            # Re-determine mode in case trigger_mode changed
478            old_mode = self._mode
479            self.load_action(self._action, self._qname)
480            # Don't reset undo if mode didn't change
481            if old_mode == self._mode:
482                pass  # undo already cleared by load_action; acceptable
483        else:
484            self._draw()

Redraw from current action (call after external parameter change).

def update_bindings(self, bound_inputs: list[str] | None = None):
486    def update_bindings(self, bound_inputs: list[str] | None = None):
487        """Update X range when bindings change (assign/unassign)."""
488        old_x_min = self._x_min
489        self._update_x_range(bound_inputs)
490        if self._x_min != old_x_min:
491            self._draw()

Update X range when bindings change (assign/unassign).