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
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).