host.controller_config.preview_widget
Interactive preview widget for the Action Editor tab.
Simulates the full analog shaping pipeline in real time with sliders, a live output dot, and a decaying history trail. Reuses the same pipeline math as the robot code (utils/input/shaping.py and utils/math/curves.py). Supports controller input via XInput and a 2D position overlay for paired stick axes.
1"""Interactive preview widget for the Action Editor tab. 2 3Simulates the full analog shaping pipeline in real time with sliders, 4a live output dot, and a decaying history trail. Reuses the same 5pipeline math as the robot code (utils/input/shaping.py and 6utils/math/curves.py). Supports controller input via XInput and a 72D position overlay for paired stick axes. 8""" 9 10import math 11import tkinter as tk 12from tkinter import ttk 13 14from host.controller_config.colors import ( 15 BG_INACTIVE, 16 BG_WHITE, 17 CURVE_LINE, 18 GRID_AXIS, 19 GRID_MAJOR, 20 GRID_MINOR, 21 LABEL_COLOR, 22) 23from host.controller_config.editor_utils import nice_grid_step 24from host.controller_config.gamepad_input import GamepadPoller 25from utils.controller.model import ( 26 ActionDefinition, 27 EventTriggerMode, 28 EXTRA_NEGATIVE_SLEW_RATE, 29 EXTRA_SEGMENT_POINTS, 30 EXTRA_SPLINE_POINTS, 31 InputType, 32 STICK_PAIRS, 33 TRIGGER_INPUTS, 34) 35from utils.math.curves import evaluate_segments, evaluate_spline 36 37 38# --------------------------------------------------------------------------- 39# Colors 40# --------------------------------------------------------------------------- 41 42_DOT_OUTLINE = "#103060" 43_TRAIL_NEWEST = (0x20, 0x60, 0xc0) # bright blue 44_TRAIL_OLDEST = (0xe0, 0xe0, 0xe0) # near-white 45_READOUT_FG = "#333333" 46 47_DOT_RADIUS = 6 48_TRAIL_MAX = 50 49_TICK_MS = 20 # ~50 fps 50 51# Motor visualization 52_MOTOR_RADIUS = 18 # outer circle radius (px) 53_MOTOR_DOT_RADIUS = 4 # rotating dot radius (px) 54_MOTOR_MARGIN = 8 # padding from plot corner 55_MOTOR_OUTLINE = "#808080" 56_MOTOR_BG = "#f8f8f8" 57_MOTOR_SPEED = 6 * math.pi # rad/s at output = 1.0 58 59# Axis-specific colors (used when both X and Y axes are active) 60_X_AXIS_COLOR = "#2060c0" # blue 61_X_AXIS_OUTLINE = "#103060" 62_X_TRAIL_NEWEST = (0x20, 0x60, 0xc0) 63_X_TRAIL_OLDEST = (0xe0, 0xe0, 0xf0) 64_Y_AXIS_COLOR = "#c04020" # red 65_Y_AXIS_OUTLINE = "#601810" 66_Y_TRAIL_NEWEST = (0xc0, 0x40, 0x20) 67_Y_TRAIL_OLDEST = (0xf0, 0xe0, 0xe0) 68 69# 2D overlay inset 70_OVERLAY_SIZE = 64 # inset square side length (px) 71_OVERLAY_MARGIN = 8 # padding from plot corner 72_OVERLAY_BG = "#f4f4f4" 73_OVERLAY_BORDER = "#a0a0a0" 74_OVERLAY_CROSSHAIR = "#d0d0d0" 75_OVERLAYCURVE_LINE = "#c04020" 76_OVERLAY_DOT_RADIUS = 4 77_OVERLAY_TRAIL_NEWEST = (0xc0, 0x40, 0x20) 78_OVERLAY_TRAIL_OLDEST = (0xe8, 0xe0, 0xde) 79_OVERLAY_TRAIL_MAX = 40 80_OVERLAY_GRID_COLOR = "#b0c8e0" # light blue for pipeline grid 81_OVERLAY_GRID_SAMPLES = 30 # points per grid line 82 83# Controller refresh interval (ms) 84_CONTROLLER_REFRESH_MS = 2000 85 86# Vertical stick axes (primary bound to one of these → swap slider mapping) 87_Y_AXES = {"left_stick_y", "right_stick_y"} 88 89 90# --------------------------------------------------------------------------- 91# SimpleSlewLimiter 92# --------------------------------------------------------------------------- 93 94class SimpleSlewLimiter: 95 """Pure-python slew rate limiter matching wpimath.filter.SlewRateLimiter. 96 97 Args: 98 pos_rate: Max rate of increase per second (positive). 99 neg_rate: Max rate of decrease per second (negative). 100 dt: Time step per ``calculate()`` call (seconds). 101 """ 102 103 def __init__(self, pos_rate: float, neg_rate: float, dt: float = 0.02): 104 self._pos_rate = abs(pos_rate) if pos_rate else 0 105 self._neg_rate = -abs(neg_rate) if neg_rate else 0 106 self._dt = dt 107 self._value = 0.0 108 109 def calculate(self, input_val: float) -> float: 110 delta = input_val - self._value 111 if self._pos_rate > 0 and delta > 0: 112 max_up = self._pos_rate * self._dt 113 delta = min(delta, max_up) 114 if self._neg_rate < 0 and delta < 0: 115 max_down = self._neg_rate * self._dt 116 delta = max(delta, max_down) 117 self._value += delta 118 return self._value 119 120 def reset(self, value: float = 0.0): 121 self._value = value 122 123 124# --------------------------------------------------------------------------- 125# Pure-python deadband (matches utils/input/shaping.py fallback) 126# --------------------------------------------------------------------------- 127 128def _apply_deadband(value: float, deadband: float) -> float: 129 if abs(value) < deadband: 130 return 0.0 131 if value > 0: 132 return (value - deadband) / (1.0 - deadband) 133 return (value + deadband) / (1.0 - deadband) 134 135 136# --------------------------------------------------------------------------- 137# PreviewWidget 138# --------------------------------------------------------------------------- 139 140class PreviewWidget(ttk.Frame): 141 """Interactive preview of the analog shaping pipeline. 142 143 Shows a 2-D plot with: 144 - X slider (horizontal) simulating the raw input (-1 to 1) 145 - Y slider (vertical) for paired axis / 2-axis preview 146 - A dot at the current (input, output) position 147 - A fading history trail of recent positions 148 - Output readout label 149 - Input source dropdown (Manual sliders or XInput controllers) 150 - 2D position overlay when paired stick axes are available 151 """ 152 153 def __init__(self, parent): 154 super().__init__(parent) 155 156 self._action: ActionDefinition | None = None 157 self._qname: str | None = None 158 159 # Pipeline closure: float -> float (None = inactive) 160 self._pipeline = None 161 self._slew: SimpleSlewLimiter | None = None 162 163 # Canvas sizing 164 self._margin_x = 35 165 self._margin_y = 30 166 self._plot_w = 0 167 self._plot_h = 0 168 169 # X-axis range: -1..1 for sticks, 0..1 for triggers 170 self._x_min = -1.0 171 172 # Y-axis range: auto-scaled from pipeline output 173 self._y_min = -1.0 174 self._y_max = 1.0 175 176 # History trail ring buffer: list of (input_x, output_y) 177 self._trail: list[tuple[float, float]] = [] 178 179 # Animation state 180 self._tick_id = None 181 self._last_input = 0.0 182 self._last_output = 0.0 183 self._syncing_slider = False # guard against slider sync loops 184 185 # Motor visualization angles (radians) — separate for X and Y axes 186 self._x_motor_angle = 0.0 187 self._y_motor_angle = 0.0 188 189 # --- Controller input --- 190 self._gamepad = GamepadPoller() 191 self._input_mode = "manual" # "manual" or int (controller index) 192 193 # Binding details: [(port, input_name), ...] 194 self._binding_details: list[tuple[int, str]] = [] 195 # Primary binding for controller reading 196 self._primary_input_name: str | None = None 197 # True when primary axis is vertical (left_stick_y, right_stick_y) 198 # — used to swap slider/overlay mapping in controller mode 199 self._primary_is_y = False 200 201 # --- Paired axis / 2D overlay --- 202 self._paired_action: ActionDefinition | None = None 203 self._paired_qname: str | None = None 204 self._paired_pipeline = None 205 self._paired_slew: SimpleSlewLimiter | None = None 206 self._paired_input_name: str | None = None 207 self._paired_trail: list[tuple[float, float]] = [] 208 self._paired_1d_trail: list[tuple[float, float]] = [] 209 self._last_paired_input = 0.0 210 self._last_paired_output = 0.0 211 212 # Controller list refresh timer 213 self._controller_refresh_id = None 214 215 self._build_ui() 216 217 # ------------------------------------------------------------------ 218 # UI Construction 219 # ------------------------------------------------------------------ 220 221 def _build_ui(self): 222 # Main area: Y slider | canvas over X slider 223 plot_area = ttk.Frame(self) 224 plot_area.pack(fill=tk.BOTH, expand=True) 225 226 # Y slider (left) 227 self._y_slider = tk.Scale( 228 plot_area, from_=-1.0, to=1.0, resolution=0.01, 229 orient=tk.VERTICAL, showvalue=False, length=100, 230 sliderlength=12, width=14, 231 command=self._on_y_slider) 232 self._y_slider.set(0.0) 233 self._y_slider.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 0), pady=2) 234 235 # Right side: canvas + X slider stacked 236 right = ttk.Frame(plot_area) 237 right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 238 239 self._canvas = tk.Canvas(right, bg=BG_INACTIVE, 240 highlightthickness=0) 241 self._canvas.pack(fill=tk.BOTH, expand=True, padx=(0, 2), pady=(2, 0)) 242 self._canvas.bind("<Configure>", self._on_canvas_configure) 243 244 # X slider (below canvas) 245 self._x_slider = tk.Scale( 246 right, from_=-1.0, to=1.0, resolution=0.01, 247 orient=tk.HORIZONTAL, showvalue=False, 248 sliderlength=12, width=14, 249 command=self._on_x_slider) 250 self._x_slider.set(0.0) 251 self._x_slider.pack(fill=tk.X, padx=(0, 2), pady=(0, 2)) 252 253 # Readout label 254 self._readout_var = tk.StringVar(value="") 255 ttk.Label(self, textvariable=self._readout_var, 256 font=("TkFixedFont", 8), anchor=tk.CENTER, 257 foreground=_READOUT_FG).pack(fill=tk.X, padx=4) 258 259 # Input source selector (replaces NT connection placeholder) 260 input_frame = ttk.Frame(self, padding=(4, 2)) 261 input_frame.pack(fill=tk.X, padx=4, pady=(0, 4)) 262 263 ttk.Label(input_frame, text="Input:", 264 font=("TkDefaultFont", 8)).pack(side=tk.LEFT, padx=(0, 4)) 265 266 self._input_source_var = tk.StringVar(value="Manual (Sliders)") 267 self._input_combo = ttk.Combobox( 268 input_frame, textvariable=self._input_source_var, 269 state="readonly", font=("TkDefaultFont", 8), width=20) 270 self._input_combo["values"] = ["Manual (Sliders)"] 271 self._input_combo.pack(side=tk.LEFT, fill=tk.X, expand=True) 272 self._input_combo.bind( 273 "<<ComboboxSelected>>", self._on_input_source_changed) 274 275 # Synced checkbox — locks X and Y sliders together in manual mode 276 self._sync_var = tk.BooleanVar(value=False) 277 self._sync_check = ttk.Checkbutton( 278 input_frame, text="Synced", variable=self._sync_var, 279 style="TCheckbutton") 280 self._sync_check.pack(side=tk.LEFT, padx=(6, 0)) 281 282 # Dual Axis checkbox — show/hide paired axis curve, motor, overlay 283 self._dual_axis_var = tk.BooleanVar(value=True) 284 self._dual_axis_check = ttk.Checkbutton( 285 input_frame, text="Dual Axis", variable=self._dual_axis_var, 286 style="TCheckbutton") 287 self._dual_axis_check.pack(side=tk.LEFT, padx=(6, 0)) 288 289 # Start periodic controller refresh 290 self._schedule_controller_refresh() 291 292 # ------------------------------------------------------------------ 293 # Public API 294 # ------------------------------------------------------------------ 295 296 297 def load_action(self, action: ActionDefinition, qname: str, 298 bound_inputs: list[str] | None = None, 299 binding_details: list[tuple[int, str]] | None = None, 300 paired_action_info: tuple | None = None): 301 """Load an action and start the preview if it's analog. 302 303 Args: 304 bound_inputs: list of input names bound to this action. 305 If any are trigger inputs (0..1 range), the X axis 306 adjusts from -1..1 to 0..1. 307 binding_details: list of (port, input_name) tuples for 308 controller input and paired axis detection. 309 paired_action_info: (ActionDefinition, qname) for the 310 paired stick axis action, or None. 311 """ 312 self._action = action 313 self._qname = qname 314 self._trail.clear() 315 self._x_motor_angle = 0.0 316 self._y_motor_angle = 0.0 317 318 # Store binding info 319 self._binding_details = binding_details or [] 320 self._primary_input_name = None 321 self._paired_input_name = None 322 self._primary_is_y = False 323 if self._binding_details: 324 _, inp = self._binding_details[0] 325 self._primary_input_name = inp 326 self._primary_is_y = inp in _Y_AXES 327 paired = STICK_PAIRS.get(inp) 328 if paired: 329 self._paired_input_name = paired 330 331 # Store paired action 332 if paired_action_info: 333 self._paired_action, self._paired_qname = paired_action_info 334 else: 335 self._paired_action = None 336 self._paired_qname = None 337 self._paired_trail.clear() 338 self._paired_1d_trail.clear() 339 self._last_paired_input = 0.0 340 self._last_paired_output = 0.0 341 342 # Detect trigger-range inputs 343 self._update_x_range(bound_inputs) 344 self._build_pipeline() 345 self._build_paired_pipeline() 346 347 if self._pipeline: 348 self._canvas.config(bg=BG_WHITE) 349 self._start_tick() 350 else: 351 self._stop_tick() 352 self._canvas.config(bg=BG_INACTIVE) 353 self._draw_inactive_message() 354 self._readout_var.set("") 355 356 def clear(self): 357 """Clear preview (no action selected).""" 358 self._action = None 359 self._qname = None 360 self._pipeline = None 361 self._slew = None 362 self._trail.clear() 363 self._binding_details = [] 364 self._primary_input_name = None 365 self._paired_action = None 366 self._paired_qname = None 367 self._paired_pipeline = None 368 self._paired_slew = None 369 self._paired_input_name = None 370 self._paired_trail.clear() 371 self._paired_1d_trail.clear() 372 self._last_paired_input = 0.0 373 self._last_paired_output = 0.0 374 self._stop_tick() 375 self._canvas.config(bg=BG_INACTIVE) 376 self._draw_inactive_message() 377 self._readout_var.set("") 378 379 def refresh(self): 380 """Rebuild pipeline from current action params (field changed).""" 381 if not self._action: 382 return 383 self._trail.clear() 384 self._paired_trail.clear() 385 self._paired_1d_trail.clear() 386 self._build_pipeline() 387 self._build_paired_pipeline() 388 if self._pipeline: 389 self._canvas.config(bg=BG_WHITE) 390 self._start_tick() 391 else: 392 self._stop_tick() 393 self._canvas.config(bg=BG_INACTIVE) 394 self._draw_inactive_message() 395 self._readout_var.set("") 396 397 def update_bindings(self, bound_inputs: list[str] | None = None, 398 binding_details: list[tuple[int, str]] | None = None, 399 paired_action_info: tuple | None = None): 400 """Update when bindings change (assign/unassign).""" 401 old_x_min = self._x_min 402 403 # Update binding details 404 self._binding_details = binding_details or [] 405 self._primary_input_name = None 406 self._paired_input_name = None 407 self._primary_is_y = False 408 if self._binding_details: 409 _, inp = self._binding_details[0] 410 self._primary_input_name = inp 411 self._primary_is_y = inp in _Y_AXES 412 paired = STICK_PAIRS.get(inp) 413 if paired: 414 self._paired_input_name = paired 415 416 # Update paired action 417 if paired_action_info: 418 self._paired_action, self._paired_qname = paired_action_info 419 else: 420 self._paired_action = None 421 self._paired_qname = None 422 self._paired_trail.clear() 423 self._paired_1d_trail.clear() 424 self._build_paired_pipeline() 425 426 self._update_x_range(bound_inputs) 427 if self._x_min != old_x_min: 428 self._trail.clear() 429 self._build_pipeline() 430 if self._pipeline: 431 self._draw() 432 433 def _update_x_range(self, bound_inputs: list[str] | None): 434 """Set X range based on bound input types. 435 436 If ALL bound inputs are triggers (0..1), use 0..1. 437 Otherwise use -1..1 (sticks, or no bindings). 438 """ 439 if bound_inputs and all( 440 inp in TRIGGER_INPUTS for inp in bound_inputs): 441 new_min = 0.0 442 else: 443 new_min = -1.0 444 445 if new_min != self._x_min: 446 self._x_min = new_min 447 # Update slider ranges 448 self._syncing_slider = True 449 self._x_slider.config(from_=new_min) 450 self._y_slider.config(from_=new_min) 451 # Clamp current value into range 452 cur = self._x_slider.get() 453 if cur < new_min: 454 self._x_slider.set(new_min) 455 self._y_slider.set(new_min) 456 self._syncing_slider = False 457 458 # ------------------------------------------------------------------ 459 # Pipeline Construction 460 # ------------------------------------------------------------------ 461 462 @staticmethod 463 def _make_pipeline_fn(action: ActionDefinition): 464 """Build a shaping closure from action parameters. 465 466 Returns (pipeline_fn, is_raw). Returns (None, False) when 467 the action is not ANALOG. 468 """ 469 if not action or action.input_type not in ( 470 InputType.ANALOG, InputType.BOOLEAN_TRIGGER): 471 return None, False 472 473 # BOOLEAN_TRIGGER: step function at threshold 474 if action.input_type == InputType.BOOLEAN_TRIGGER: 475 threshold = action.threshold 476 return (lambda raw: 1.0 if raw > threshold else 0.0), False 477 478 mode = action.trigger_mode 479 if mode == EventTriggerMode.RAW: 480 return (lambda raw: raw), True 481 482 inversion = action.inversion 483 deadband = action.deadband 484 scale = action.scale 485 extra = action.extra or {} 486 spline_pts = extra.get(EXTRA_SPLINE_POINTS) 487 segment_pts = extra.get(EXTRA_SEGMENT_POINTS) 488 489 if mode == EventTriggerMode.SQUARED: 490 def pipeline(raw): 491 v = -raw if inversion else raw 492 v = _apply_deadband(v, deadband) if deadband > 0 else v 493 v = math.copysign(v * v, v) 494 return v * scale 495 elif mode == EventTriggerMode.SPLINE and spline_pts: 496 def pipeline(raw): 497 v = -raw if inversion else raw 498 v = _apply_deadband(v, deadband) if deadband > 0 else v 499 v = evaluate_spline(spline_pts, v) 500 return v * scale 501 elif mode == EventTriggerMode.SEGMENTED and segment_pts: 502 def pipeline(raw): 503 v = -raw if inversion else raw 504 v = _apply_deadband(v, deadband) if deadband > 0 else v 505 v = evaluate_segments(segment_pts, v) 506 return v * scale 507 else: 508 # SCALED (and fallback) 509 def pipeline(raw): 510 v = -raw if inversion else raw 511 v = _apply_deadband(v, deadband) if deadband > 0 else v 512 return v * scale 513 514 return pipeline, False 515 516 @staticmethod 517 def _make_slew_limiter(action: ActionDefinition): 518 """Build a SimpleSlewLimiter from action params, or None.""" 519 slew_rate = action.slew_rate 520 if slew_rate > 0: 521 extra = action.extra or {} 522 neg_rate = extra.get(EXTRA_NEGATIVE_SLEW_RATE) 523 if neg_rate is None: 524 neg_rate = -slew_rate 525 else: 526 neg_rate = float(neg_rate) 527 return SimpleSlewLimiter( 528 slew_rate, neg_rate, dt=_TICK_MS / 1000.0) 529 return None 530 531 def _build_pipeline(self): 532 """Build the primary shaping pipeline from the current action.""" 533 self._pipeline, is_raw = self._make_pipeline_fn(self._action) 534 if self._pipeline is None: 535 self._slew = None 536 return 537 if is_raw: 538 self._y_min = -1.0 539 self._y_max = 1.0 540 self._slew = None 541 return 542 self._compute_y_range() 543 self._slew = self._make_slew_limiter(self._action) 544 545 def _build_paired_pipeline(self): 546 """Build a pipeline for the paired stick axis. 547 548 If a paired action exists, build its full shaping pipeline. 549 If no paired action but the input is a stick axis, use passthrough 550 so the 2D overlay still shows raw paired-axis values. 551 Recomputes Y range so both pipelines fit on the 1D plot. 552 """ 553 if self._paired_action: 554 self._paired_pipeline, is_raw = self._make_pipeline_fn( 555 self._paired_action) 556 if self._paired_pipeline and not is_raw: 557 self._paired_slew = self._make_slew_limiter( 558 self._paired_action) 559 else: 560 self._paired_slew = None 561 elif self._paired_input_name: 562 # Stick axis with no paired action — passthrough 563 self._paired_pipeline = lambda raw: raw 564 self._paired_slew = None 565 else: 566 self._paired_pipeline = None 567 self._paired_slew = None 568 # Recompute Y range to include both pipelines 569 if self._pipeline: 570 self._compute_y_range() 571 572 @property 573 def _show_paired(self): 574 """True when paired axis should be drawn and processed.""" 575 return (self._paired_pipeline is not None 576 and self._dual_axis_var.get()) 577 578 _Y_RANGE_SAMPLES = 200 579 580 def _compute_y_range(self): 581 """Auto-scale Y axis by sampling pipeline output across x range. 582 583 Also samples the paired pipeline so both curves fit on the plot. 584 """ 585 if not self._pipeline: 586 self._y_min = -1.0 587 self._y_max = 1.0 588 return 589 y_min = 0.0 590 y_max = 0.0 591 n = self._Y_RANGE_SAMPLES 592 x_span = 1.0 - self._x_min 593 pipelines = [self._pipeline] 594 if self._show_paired: 595 pipelines.append(self._paired_pipeline) 596 for pipe in pipelines: 597 for i in range(n + 1): 598 x = self._x_min + x_span * i / n 599 y = pipe(x) 600 y_min = min(y_min, y) 601 y_max = max(y_max, y) 602 # Ensure range always includes at least -1..1 603 y_min = min(y_min, -1.0) 604 y_max = max(y_max, 1.0) 605 pad = (y_max - y_min) * 0.05 606 self._y_min = y_min - pad 607 self._y_max = y_max + pad 608 609 @staticmethod 610 def _nice_grid_step(span: float) -> float: 611 """Choose a nice gridline step for the given data span.""" 612 return nice_grid_step(span) 613 614 # ------------------------------------------------------------------ 615 # Canvas Sizing & Coordinate Conversion 616 # ------------------------------------------------------------------ 617 618 def _on_canvas_configure(self, event): 619 w, h = event.width, event.height 620 if w < 20 or h < 20: 621 return 622 623 mx = max(25, min(50, int(w * 0.08))) 624 my = max(25, min(50, int(h * 0.08))) 625 self._margin_x = mx 626 self._margin_y = my 627 self._plot_w = max(10, w - 2 * mx) 628 self._plot_h = max(10, h - 2 * my) 629 630 if self._pipeline: 631 self._draw() 632 else: 633 self._draw_inactive_message() 634 635 def _d2c(self, x: float, y: float) -> tuple[float, float]: 636 """Data coords to canvas pixels. X uses _x_min..1, Y uses _y_min/_y_max.""" 637 x_range = 1.0 - self._x_min 638 if x_range == 0: 639 x_range = 2.0 640 cx = self._margin_x + (x - self._x_min) / x_range * self._plot_w 641 y_range = self._y_max - self._y_min 642 if y_range == 0: 643 y_range = 2.0 644 cy = self._margin_y + (self._y_max - y) / y_range * self._plot_h 645 return cx, cy 646 647 def _c2d(self, cx: float, cy: float) -> tuple[float, float]: 648 """Canvas pixel coords to data coords.""" 649 if self._plot_w == 0 or self._plot_h == 0: 650 return 0.0, 0.0 651 x_range = 1.0 - self._x_min 652 if x_range == 0: 653 x_range = 2.0 654 x = (cx - self._margin_x) / self._plot_w * x_range + self._x_min 655 y_range = self._y_max - self._y_min 656 if y_range == 0: 657 y_range = 2.0 658 y = self._y_max - (cy - self._margin_y) / self._plot_h * y_range 659 return x, y 660 661 # ------------------------------------------------------------------ 662 # Drawing 663 # ------------------------------------------------------------------ 664 665 def _draw(self): 666 """Full redraw: grid, trail, current dot.""" 667 c = self._canvas 668 c.delete("all") 669 if self._plot_w < 10: 670 return 671 672 self._draw_grid() 673 self._draw_trail() 674 self._draw_current() 675 self._draw_motors() 676 if self._show_paired: 677 self._draw_2d_overlay() 678 self._draw_legend() 679 680 def _draw_inactive_message(self): 681 """Show an inactive placeholder message.""" 682 c = self._canvas 683 c.delete("all") 684 w = c.winfo_width() 685 h = c.winfo_height() 686 if w < 10 or h < 10: 687 return 688 689 if not self._action: 690 msg = "Select an action" 691 elif self._action.input_type == InputType.BUTTON: 692 msg = "Button \u2014 no analog preview" 693 elif self._action.input_type == InputType.OUTPUT: 694 msg = "Output \u2014 no analog preview" 695 else: 696 msg = "No preview available" 697 c.create_text(w // 2, h // 2, text=msg, 698 fill="#888888", font=("TkDefaultFont", 10)) 699 700 def _draw_grid(self): 701 """Draw gridlines and axis labels (dynamic Y range).""" 702 c = self._canvas 703 mx = self._margin_x 704 my = self._margin_y 705 pw = self._plot_w 706 ph = self._plot_h 707 font = ("TkDefaultFont", 7) 708 709 # X gridlines (dynamic based on x_min) 710 if self._x_min >= 0: 711 x_vals = [0.0, 0.25, 0.5, 0.75, 1.0] 712 else: 713 x_vals = [-1.0, -0.5, 0.0, 0.5, 1.0] 714 for v in x_vals: 715 cx, _ = self._d2c(v, 0) 716 is_axis = abs(v) < 0.01 717 is_boundary = abs(v - self._x_min) < 0.01 or abs(v - 1.0) < 0.01 718 color = GRID_AXIS if is_axis else ( 719 GRID_MAJOR if is_boundary else GRID_MINOR) 720 w = 1.5 if is_axis else 1 721 c.create_line(cx, my, cx, my + ph, fill=color, width=w) 722 723 # Y gridlines (dynamic range) 724 y_step = self._nice_grid_step(self._y_max - self._y_min) 725 y_start = math.floor(self._y_min / y_step) * y_step 726 v = y_start 727 while v <= self._y_max + y_step * 0.01: 728 _, cy = self._d2c(0, v) 729 is_axis = abs(v) < y_step * 0.01 730 color = GRID_AXIS if is_axis else GRID_MAJOR 731 w = 1.5 if is_axis else 1 732 c.create_line(mx, cy, mx + pw, cy, fill=color, width=w) 733 v += y_step 734 735 # X labels (below) 736 for v in x_vals: 737 cx, _ = self._d2c(v, 0) 738 c.create_text(cx, my + ph + 12, 739 text=f"{v:g}", fill=LABEL_COLOR, font=font) 740 741 # Y labels (left, dynamic) 742 v = y_start 743 while v <= self._y_max + y_step * 0.01: 744 _, cy = self._d2c(0, v) 745 c.create_text(mx - 18, cy, 746 text=f"{v:g}", fill=LABEL_COLOR, font=font) 747 v += y_step 748 749 # Reference lines at +/-1 when Y extends beyond 750 if self._y_min < -1.05 or self._y_max > 1.05: 751 for ref_y in (-1.0, 1.0): 752 _, ry = self._d2c(0, ref_y) 753 c.create_line(mx, ry, mx + pw, ry, 754 fill="#b0b0ff", width=1, dash=(6, 3)) 755 756 # Border 757 c.create_rectangle(mx, my, mx + pw, my + ph, outline="#808080") 758 759 def _draw_trail_data(self, trail, newest_color, oldest_color): 760 """Draw a fading trail from oldest (dim) to newest (bright).""" 761 c = self._canvas 762 n = len(trail) 763 if n == 0: 764 return 765 for i, (tx, ty) in enumerate(trail): 766 frac = i / max(n - 1, 1) 767 r = int(oldest_color[0] + frac * ( 768 newest_color[0] - oldest_color[0])) 769 g = int(oldest_color[1] + frac * ( 770 newest_color[1] - oldest_color[1])) 771 b = int(oldest_color[2] + frac * ( 772 newest_color[2] - oldest_color[2])) 773 color = f"#{r:02x}{g:02x}{b:02x}" 774 cx, cy = self._d2c(tx, ty) 775 radius = 2 + frac * 2 776 c.create_oval(cx - radius, cy - radius, 777 cx + radius, cy + radius, 778 fill=color, outline="") 779 780 def _draw_trail(self): 781 """Draw fading history dots for primary and paired pipelines.""" 782 if self._show_paired: 783 if self._primary_is_y: 784 prim_new, prim_old = _Y_TRAIL_NEWEST, _Y_TRAIL_OLDEST 785 pair_new, pair_old = _X_TRAIL_NEWEST, _X_TRAIL_OLDEST 786 else: 787 prim_new, prim_old = _X_TRAIL_NEWEST, _X_TRAIL_OLDEST 788 pair_new, pair_old = _Y_TRAIL_NEWEST, _Y_TRAIL_OLDEST 789 self._draw_trail_data( 790 self._paired_1d_trail, pair_new, pair_old) 791 self._draw_trail_data(self._trail, prim_new, prim_old) 792 else: 793 self._draw_trail_data( 794 self._trail, _TRAIL_NEWEST, _TRAIL_OLDEST) 795 796 def _draw_current(self): 797 """Draw the large dot at current (input, output).""" 798 c = self._canvas 799 if self._show_paired: 800 if self._primary_is_y: 801 prim_fill, prim_out = _Y_AXIS_COLOR, _Y_AXIS_OUTLINE 802 pair_fill, pair_out = _X_AXIS_COLOR, _X_AXIS_OUTLINE 803 else: 804 prim_fill, prim_out = _X_AXIS_COLOR, _X_AXIS_OUTLINE 805 pair_fill, pair_out = _Y_AXIS_COLOR, _Y_AXIS_OUTLINE 806 # Paired dot (draw first so primary is on top) 807 px, py = self._d2c( 808 self._last_paired_input, self._last_paired_output) 809 c.create_oval(px - _DOT_RADIUS, py - _DOT_RADIUS, 810 px + _DOT_RADIUS, py + _DOT_RADIUS, 811 fill=pair_fill, outline=pair_out, width=1.5) 812 # Primary dot 813 cx, cy = self._d2c(self._last_input, self._last_output) 814 c.create_oval(cx - _DOT_RADIUS, cy - _DOT_RADIUS, 815 cx + _DOT_RADIUS, cy + _DOT_RADIUS, 816 fill=prim_fill, outline=prim_out, width=1.5) 817 else: 818 cx, cy = self._d2c(self._last_input, self._last_output) 819 c.create_oval(cx - _DOT_RADIUS, cy - _DOT_RADIUS, 820 cx + _DOT_RADIUS, cy + _DOT_RADIUS, 821 fill=CURVE_LINE, outline=_DOT_OUTLINE, width=1.5) 822 823 def _draw_motor_at(self, cx, cy, angle, label, color): 824 """Draw a spinning motor indicator at the given center position.""" 825 c = self._canvas 826 827 # Outer circle 828 c.create_oval( 829 cx - _MOTOR_RADIUS, cy - _MOTOR_RADIUS, 830 cx + _MOTOR_RADIUS, cy + _MOTOR_RADIUS, 831 outline=_MOTOR_OUTLINE, fill=_MOTOR_BG, width=1.5) 832 833 # Rotating dot on the rim 834 orbit_r = _MOTOR_RADIUS - _MOTOR_DOT_RADIUS - 2 835 dot_x = cx + orbit_r * math.cos(angle) 836 dot_y = cy + orbit_r * math.sin(angle) 837 c.create_oval( 838 dot_x - _MOTOR_DOT_RADIUS, dot_y - _MOTOR_DOT_RADIUS, 839 dot_x + _MOTOR_DOT_RADIUS, dot_y + _MOTOR_DOT_RADIUS, 840 fill=color, outline="") 841 842 # Label above motor 843 c.create_text(cx, cy - _MOTOR_RADIUS - 6, text=label, 844 fill=color, font=("TkDefaultFont", 7)) 845 846 def _draw_motors(self): 847 """Draw X and Y motor indicators when their axes are active.""" 848 mx = self._margin_x 849 my = self._margin_y 850 pw = self._plot_w 851 ph = self._plot_h 852 853 # Determine which axes have active outputs 854 both = self._show_paired 855 if self._primary_is_y: 856 y_active = self._pipeline is not None 857 x_active = both 858 else: 859 x_active = self._pipeline is not None 860 y_active = both 861 862 # Right motor (X axis) — bottom-right corner 863 if x_active: 864 rcx = mx + pw - _MOTOR_RADIUS - _MOTOR_MARGIN 865 rcy = my + ph - _MOTOR_RADIUS - _MOTOR_MARGIN 866 x_color = _X_AXIS_COLOR if both else CURVE_LINE 867 self._draw_motor_at( 868 rcx, rcy, self._x_motor_angle, "X", x_color) 869 870 # Left motor (Y axis) — bottom-left corner 871 if y_active: 872 lcx = mx + _MOTOR_RADIUS + _MOTOR_MARGIN 873 lcy = my + ph - _MOTOR_RADIUS - _MOTOR_MARGIN 874 y_color = _Y_AXIS_COLOR if both else CURVE_LINE 875 self._draw_motor_at( 876 lcx, lcy, self._y_motor_angle, "Y", y_color) 877 878 def _draw_legend(self): 879 """Draw axis color legend in top-right corner of plot.""" 880 c = self._canvas 881 mx = self._margin_x 882 my = self._margin_y 883 pw = self._plot_w 884 font = ("TkDefaultFont", 7) 885 dot_r = 3 886 line_h = 12 887 pad = 6 888 889 # Position: top-right corner, inset 890 rx = mx + pw - pad 891 ry = my + pad 892 893 # X axis entry (blue) 894 c.create_oval(rx - 40 - dot_r, ry - dot_r, 895 rx - 40 + dot_r, ry + dot_r, 896 fill=_X_AXIS_COLOR, outline="") 897 c.create_text(rx - 40 + dot_r + 4, ry, text="X axis", 898 anchor=tk.W, fill=_X_AXIS_COLOR, font=font) 899 900 # Y axis entry (red) 901 ry2 = ry + line_h 902 c.create_oval(rx - 40 - dot_r, ry2 - dot_r, 903 rx - 40 + dot_r, ry2 + dot_r, 904 fill=_Y_AXIS_COLOR, outline="") 905 c.create_text(rx - 40 + dot_r + 4, ry2, text="Y axis", 906 anchor=tk.W, fill=_Y_AXIS_COLOR, font=font) 907 908 def _draw_2d_overlay(self): 909 """Draw 2D position inset in top-left of plot area.""" 910 c = self._canvas 911 mx = self._margin_x 912 my = self._margin_y 913 size = _OVERLAY_SIZE 914 915 # Inset position: top-left corner 916 ox = mx + _OVERLAY_MARGIN 917 oy = my + _OVERLAY_MARGIN 918 919 # Background 920 c.create_rectangle(ox, oy, ox + size, oy + size, 921 fill=_OVERLAY_BG, outline=_OVERLAY_BORDER, 922 width=1) 923 924 # Crosshair at center 925 cx_center = ox + size / 2 926 cy_center = oy + size / 2 927 c.create_line(ox + 2, cy_center, ox + size - 2, cy_center, 928 fill=_OVERLAY_CROSSHAIR, width=1) 929 c.create_line(cx_center, oy + 2, cx_center, oy + size - 2, 930 fill=_OVERLAY_CROSSHAIR, width=1) 931 932 # Map -1..1 to overlay pixel coords 933 def ov_xy(xv, yv): 934 px = ox + (xv + 1.0) / 2.0 * size 935 py = oy + (1.0 - (yv + 1.0) / 2.0) * size 936 return px, py 937 938 # Determine which pipeline maps to X vs Y in the overlay 939 if self._primary_is_y: 940 x_pipe = self._paired_pipeline 941 y_pipe = self._pipeline 942 else: 943 x_pipe = self._pipeline 944 y_pipe = self._paired_pipeline 945 946 # Draw warped grid showing both pipeline responses (static, 947 # no slew). Vertical grid lines: fixed x input, sweep y. 948 # Horizontal grid lines: fixed y input, sweep x. 949 n = _OVERLAY_GRID_SAMPLES 950 grid_vals = [-1.0, -0.5, 0.0, 0.5, 1.0] 951 952 for gx in grid_vals: 953 x_out = x_pipe(gx) 954 pts = [] 955 for i in range(n + 1): 956 y_in = -1.0 + 2.0 * i / n 957 y_out = y_pipe(y_in) 958 pts.extend(ov_xy(x_out, y_out)) 959 if len(pts) >= 4: 960 c.create_line(*pts, fill=_OVERLAY_GRID_COLOR, width=1) 961 962 for gy in grid_vals: 963 y_out = y_pipe(gy) 964 pts = [] 965 for i in range(n + 1): 966 x_in = -1.0 + 2.0 * i / n 967 x_out = x_pipe(x_in) 968 pts.extend(ov_xy(x_out, y_out)) 969 if len(pts) >= 4: 970 c.create_line(*pts, fill=_OVERLAY_GRID_COLOR, width=1) 971 972 # Trail 973 n_trail = len(self._paired_trail) 974 for i, (tx, ty) in enumerate(self._paired_trail): 975 frac = i / max(n_trail - 1, 1) 976 r = int(_OVERLAY_TRAIL_OLDEST[0] + frac * ( 977 _OVERLAY_TRAIL_NEWEST[0] - _OVERLAY_TRAIL_OLDEST[0])) 978 g = int(_OVERLAY_TRAIL_OLDEST[1] + frac * ( 979 _OVERLAY_TRAIL_NEWEST[1] - _OVERLAY_TRAIL_OLDEST[1])) 980 b = int(_OVERLAY_TRAIL_OLDEST[2] + frac * ( 981 _OVERLAY_TRAIL_NEWEST[2] - _OVERLAY_TRAIL_OLDEST[2])) 982 color = f"#{r:02x}{g:02x}{b:02x}" 983 px, py = ov_xy(tx, ty) 984 radius = 1 + frac 985 c.create_oval(px - radius, py - radius, 986 px + radius, py + radius, 987 fill=color, outline="") 988 989 # Current dot — use correct physical mapping 990 if self._primary_is_y: 991 px, py = ov_xy(self._last_paired_output, self._last_output) 992 else: 993 px, py = ov_xy(self._last_output, self._last_paired_output) 994 c.create_oval( 995 px - _OVERLAY_DOT_RADIUS, py - _OVERLAY_DOT_RADIUS, 996 px + _OVERLAY_DOT_RADIUS, py + _OVERLAY_DOT_RADIUS, 997 fill=_OVERLAYCURVE_LINE, outline="") 998 999 # Label — show stick name if known 1000 if self._primary_input_name and "left" in self._primary_input_name: 1001 label = "L Stick" 1002 elif self._primary_input_name and "right" in self._primary_input_name: 1003 label = "R Stick" 1004 else: 1005 label = "2D" 1006 c.create_text(ox + 3, oy + 3, text=label, anchor=tk.NW, 1007 fill=_OVERLAY_BORDER, font=("TkDefaultFont", 6)) 1008 1009 # ------------------------------------------------------------------ 1010 # Slider Callbacks 1011 # ------------------------------------------------------------------ 1012 1013 def _on_x_slider(self, val): 1014 """X slider changed — sync Y slider when synced.""" 1015 if self._syncing_slider: 1016 return 1017 if self._sync_var.get(): 1018 self._syncing_slider = True 1019 self._y_slider.set(float(val)) 1020 self._syncing_slider = False 1021 1022 def _on_y_slider(self, val): 1023 """Y slider changed — sync X slider when synced.""" 1024 if self._syncing_slider: 1025 return 1026 if self._sync_var.get(): 1027 self._syncing_slider = True 1028 self._x_slider.set(float(val)) 1029 self._syncing_slider = False 1030 1031 # ------------------------------------------------------------------ 1032 # Input Source Management 1033 # ------------------------------------------------------------------ 1034 1035 def _on_input_source_changed(self, event=None): 1036 """Handle input source dropdown selection.""" 1037 selection = self._input_source_var.get() 1038 if selection.startswith("Controller"): 1039 try: 1040 idx = int(selection.split()[1].rstrip(":")) 1041 self._input_mode = idx 1042 except (ValueError, IndexError): 1043 self._input_mode = "manual" 1044 else: 1045 self._input_mode = "manual" 1046 # Grey out sync checkbox on controller, enable on manual 1047 if self._input_mode == "manual": 1048 self._sync_check.state(["!disabled"]) 1049 else: 1050 self._sync_check.state(["disabled"]) 1051 # Clear trails when switching input source 1052 self._trail.clear() 1053 self._paired_trail.clear() 1054 self._paired_1d_trail.clear() 1055 if self._slew: 1056 self._slew.reset() 1057 if self._paired_slew: 1058 self._paired_slew.reset() 1059 1060 def _schedule_controller_refresh(self): 1061 """Schedule periodic controller enumeration.""" 1062 self._refresh_controller_list() 1063 self._controller_refresh_id = self.after( 1064 _CONTROLLER_REFRESH_MS, self._schedule_controller_refresh) 1065 1066 def _refresh_controller_list(self): 1067 """Update the input source dropdown with connected controllers.""" 1068 values = ["Manual (Sliders)"] 1069 if self._gamepad.available: 1070 for idx in self._gamepad.get_connected(): 1071 values.append(f"Controller {idx}") 1072 else: 1073 values.append("(Install XInput-Python for gamepad)") 1074 1075 current = self._input_source_var.get() 1076 self._input_combo["values"] = values 1077 1078 # If currently selected controller disconnected, fall back 1079 if current not in values: 1080 self._input_source_var.set("Manual (Sliders)") 1081 self._input_mode = "manual" 1082 1083 # ------------------------------------------------------------------ 1084 # Animation Loop 1085 # ------------------------------------------------------------------ 1086 1087 def _start_tick(self): 1088 """Start the animation loop if not already running.""" 1089 if self._tick_id is None: 1090 self._tick() 1091 1092 def _stop_tick(self): 1093 """Stop the animation loop.""" 1094 if self._tick_id is not None: 1095 self.after_cancel(self._tick_id) 1096 self._tick_id = None 1097 1098 def _tick(self): 1099 """One animation frame: compute output, update trail, redraw.""" 1100 if not self._pipeline: 1101 self._tick_id = None 1102 return 1103 1104 # --- Read primary input --- 1105 controller_active = ( 1106 self._input_mode != "manual" 1107 and self._primary_input_name 1108 and self._gamepad.available) 1109 1110 if controller_active: 1111 # Controller mode: read axis from gamepad 1112 raw_input = self._gamepad.get_axis( 1113 self._input_mode, self._primary_input_name) 1114 # Sync slider to show controller value. 1115 # If primary is a Y-type axis (e.g. left_stick_y), show it on 1116 # the vertical slider; otherwise on the horizontal slider. 1117 self._syncing_slider = True 1118 if self._primary_is_y: 1119 self._y_slider.set(raw_input) 1120 else: 1121 self._x_slider.set(raw_input) 1122 self._syncing_slider = False 1123 else: 1124 # Manual mode: read from X slider (primary input) 1125 raw_input = self._x_slider.get() 1126 1127 # Run through shaping pipeline 1128 shaped = self._pipeline(raw_input) 1129 1130 # Apply slew rate limiter 1131 if self._slew: 1132 output = self._slew.calculate(shaped) 1133 else: 1134 output = shaped 1135 1136 self._last_input = raw_input 1137 self._last_output = output 1138 1139 # Advance motor angles: output=1 → full speed 1140 dt = _TICK_MS / 1000.0 1141 if self._primary_is_y: 1142 self._y_motor_angle += output * _MOTOR_SPEED * dt 1143 else: 1144 self._x_motor_angle += output * _MOTOR_SPEED * dt 1145 1146 # Append to trail 1147 self._trail.append((raw_input, output)) 1148 if len(self._trail) > _TRAIL_MAX: 1149 self._trail.pop(0) 1150 1151 # --- Paired axis (2D overlay) --- 1152 if self._show_paired: 1153 if controller_active and self._paired_input_name: 1154 y_raw = self._gamepad.get_axis( 1155 self._input_mode, self._paired_input_name) 1156 # Put paired axis on the opposite slider 1157 self._syncing_slider = True 1158 if self._primary_is_y: 1159 self._x_slider.set(y_raw) 1160 else: 1161 self._y_slider.set(y_raw) 1162 self._syncing_slider = False 1163 else: 1164 y_raw = self._y_slider.get() 1165 1166 y_shaped = self._paired_pipeline(y_raw) 1167 if self._paired_slew: 1168 y_out = self._paired_slew.calculate(y_shaped) 1169 else: 1170 y_out = y_shaped 1171 self._last_paired_input = y_raw 1172 self._last_paired_output = y_out 1173 1174 # Append to 1D trail for paired pipeline 1175 self._paired_1d_trail.append((y_raw, y_out)) 1176 if len(self._paired_1d_trail) > _TRAIL_MAX: 1177 self._paired_1d_trail.pop(0) 1178 1179 # Advance the paired axis motor angle 1180 if self._primary_is_y: 1181 self._x_motor_angle += y_out * _MOTOR_SPEED * dt 1182 else: 1183 self._y_motor_angle += y_out * _MOTOR_SPEED * dt 1184 1185 # Store trail with correct physical mapping: 1186 # (x_value, y_value) where x=horizontal, y=vertical 1187 if self._primary_is_y: 1188 self._paired_trail.append((y_out, output)) 1189 else: 1190 self._paired_trail.append((output, y_out)) 1191 if len(self._paired_trail) > _OVERLAY_TRAIL_MAX: 1192 self._paired_trail.pop(0) 1193 1194 # Update readout 1195 if self._show_paired: 1196 pi = self._last_paired_input 1197 po = self._last_paired_output 1198 if self._primary_is_y: 1199 self._readout_var.set( 1200 f"X: {pi:+.3f}\u2192{po:+.3f} " 1201 f"Y: {raw_input:+.3f}\u2192{output:+.3f}") 1202 else: 1203 self._readout_var.set( 1204 f"X: {raw_input:+.3f}\u2192{output:+.3f} " 1205 f"Y: {pi:+.3f}\u2192{po:+.3f}") 1206 else: 1207 self._readout_var.set( 1208 f"In: {raw_input:+.3f} \u2192 Out: {output:+.3f}") 1209 1210 # Redraw 1211 self._draw() 1212 1213 # Schedule next tick 1214 self._tick_id = self.after(_TICK_MS, self._tick)
95class SimpleSlewLimiter: 96 """Pure-python slew rate limiter matching wpimath.filter.SlewRateLimiter. 97 98 Args: 99 pos_rate: Max rate of increase per second (positive). 100 neg_rate: Max rate of decrease per second (negative). 101 dt: Time step per ``calculate()`` call (seconds). 102 """ 103 104 def __init__(self, pos_rate: float, neg_rate: float, dt: float = 0.02): 105 self._pos_rate = abs(pos_rate) if pos_rate else 0 106 self._neg_rate = -abs(neg_rate) if neg_rate else 0 107 self._dt = dt 108 self._value = 0.0 109 110 def calculate(self, input_val: float) -> float: 111 delta = input_val - self._value 112 if self._pos_rate > 0 and delta > 0: 113 max_up = self._pos_rate * self._dt 114 delta = min(delta, max_up) 115 if self._neg_rate < 0 and delta < 0: 116 max_down = self._neg_rate * self._dt 117 delta = max(delta, max_down) 118 self._value += delta 119 return self._value 120 121 def reset(self, value: float = 0.0): 122 self._value = value
Pure-python slew rate limiter matching wpimath.filter.SlewRateLimiter.
Args:
pos_rate: Max rate of increase per second (positive).
neg_rate: Max rate of decrease per second (negative).
dt: Time step per calculate() call (seconds).
110 def calculate(self, input_val: float) -> float: 111 delta = input_val - self._value 112 if self._pos_rate > 0 and delta > 0: 113 max_up = self._pos_rate * self._dt 114 delta = min(delta, max_up) 115 if self._neg_rate < 0 and delta < 0: 116 max_down = self._neg_rate * self._dt 117 delta = max(delta, max_down) 118 self._value += delta 119 return self._value
141class PreviewWidget(ttk.Frame): 142 """Interactive preview of the analog shaping pipeline. 143 144 Shows a 2-D plot with: 145 - X slider (horizontal) simulating the raw input (-1 to 1) 146 - Y slider (vertical) for paired axis / 2-axis preview 147 - A dot at the current (input, output) position 148 - A fading history trail of recent positions 149 - Output readout label 150 - Input source dropdown (Manual sliders or XInput controllers) 151 - 2D position overlay when paired stick axes are available 152 """ 153 154 def __init__(self, parent): 155 super().__init__(parent) 156 157 self._action: ActionDefinition | None = None 158 self._qname: str | None = None 159 160 # Pipeline closure: float -> float (None = inactive) 161 self._pipeline = None 162 self._slew: SimpleSlewLimiter | None = None 163 164 # Canvas sizing 165 self._margin_x = 35 166 self._margin_y = 30 167 self._plot_w = 0 168 self._plot_h = 0 169 170 # X-axis range: -1..1 for sticks, 0..1 for triggers 171 self._x_min = -1.0 172 173 # Y-axis range: auto-scaled from pipeline output 174 self._y_min = -1.0 175 self._y_max = 1.0 176 177 # History trail ring buffer: list of (input_x, output_y) 178 self._trail: list[tuple[float, float]] = [] 179 180 # Animation state 181 self._tick_id = None 182 self._last_input = 0.0 183 self._last_output = 0.0 184 self._syncing_slider = False # guard against slider sync loops 185 186 # Motor visualization angles (radians) — separate for X and Y axes 187 self._x_motor_angle = 0.0 188 self._y_motor_angle = 0.0 189 190 # --- Controller input --- 191 self._gamepad = GamepadPoller() 192 self._input_mode = "manual" # "manual" or int (controller index) 193 194 # Binding details: [(port, input_name), ...] 195 self._binding_details: list[tuple[int, str]] = [] 196 # Primary binding for controller reading 197 self._primary_input_name: str | None = None 198 # True when primary axis is vertical (left_stick_y, right_stick_y) 199 # — used to swap slider/overlay mapping in controller mode 200 self._primary_is_y = False 201 202 # --- Paired axis / 2D overlay --- 203 self._paired_action: ActionDefinition | None = None 204 self._paired_qname: str | None = None 205 self._paired_pipeline = None 206 self._paired_slew: SimpleSlewLimiter | None = None 207 self._paired_input_name: str | None = None 208 self._paired_trail: list[tuple[float, float]] = [] 209 self._paired_1d_trail: list[tuple[float, float]] = [] 210 self._last_paired_input = 0.0 211 self._last_paired_output = 0.0 212 213 # Controller list refresh timer 214 self._controller_refresh_id = None 215 216 self._build_ui() 217 218 # ------------------------------------------------------------------ 219 # UI Construction 220 # ------------------------------------------------------------------ 221 222 def _build_ui(self): 223 # Main area: Y slider | canvas over X slider 224 plot_area = ttk.Frame(self) 225 plot_area.pack(fill=tk.BOTH, expand=True) 226 227 # Y slider (left) 228 self._y_slider = tk.Scale( 229 plot_area, from_=-1.0, to=1.0, resolution=0.01, 230 orient=tk.VERTICAL, showvalue=False, length=100, 231 sliderlength=12, width=14, 232 command=self._on_y_slider) 233 self._y_slider.set(0.0) 234 self._y_slider.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 0), pady=2) 235 236 # Right side: canvas + X slider stacked 237 right = ttk.Frame(plot_area) 238 right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 239 240 self._canvas = tk.Canvas(right, bg=BG_INACTIVE, 241 highlightthickness=0) 242 self._canvas.pack(fill=tk.BOTH, expand=True, padx=(0, 2), pady=(2, 0)) 243 self._canvas.bind("<Configure>", self._on_canvas_configure) 244 245 # X slider (below canvas) 246 self._x_slider = tk.Scale( 247 right, from_=-1.0, to=1.0, resolution=0.01, 248 orient=tk.HORIZONTAL, showvalue=False, 249 sliderlength=12, width=14, 250 command=self._on_x_slider) 251 self._x_slider.set(0.0) 252 self._x_slider.pack(fill=tk.X, padx=(0, 2), pady=(0, 2)) 253 254 # Readout label 255 self._readout_var = tk.StringVar(value="") 256 ttk.Label(self, textvariable=self._readout_var, 257 font=("TkFixedFont", 8), anchor=tk.CENTER, 258 foreground=_READOUT_FG).pack(fill=tk.X, padx=4) 259 260 # Input source selector (replaces NT connection placeholder) 261 input_frame = ttk.Frame(self, padding=(4, 2)) 262 input_frame.pack(fill=tk.X, padx=4, pady=(0, 4)) 263 264 ttk.Label(input_frame, text="Input:", 265 font=("TkDefaultFont", 8)).pack(side=tk.LEFT, padx=(0, 4)) 266 267 self._input_source_var = tk.StringVar(value="Manual (Sliders)") 268 self._input_combo = ttk.Combobox( 269 input_frame, textvariable=self._input_source_var, 270 state="readonly", font=("TkDefaultFont", 8), width=20) 271 self._input_combo["values"] = ["Manual (Sliders)"] 272 self._input_combo.pack(side=tk.LEFT, fill=tk.X, expand=True) 273 self._input_combo.bind( 274 "<<ComboboxSelected>>", self._on_input_source_changed) 275 276 # Synced checkbox — locks X and Y sliders together in manual mode 277 self._sync_var = tk.BooleanVar(value=False) 278 self._sync_check = ttk.Checkbutton( 279 input_frame, text="Synced", variable=self._sync_var, 280 style="TCheckbutton") 281 self._sync_check.pack(side=tk.LEFT, padx=(6, 0)) 282 283 # Dual Axis checkbox — show/hide paired axis curve, motor, overlay 284 self._dual_axis_var = tk.BooleanVar(value=True) 285 self._dual_axis_check = ttk.Checkbutton( 286 input_frame, text="Dual Axis", variable=self._dual_axis_var, 287 style="TCheckbutton") 288 self._dual_axis_check.pack(side=tk.LEFT, padx=(6, 0)) 289 290 # Start periodic controller refresh 291 self._schedule_controller_refresh() 292 293 # ------------------------------------------------------------------ 294 # Public API 295 # ------------------------------------------------------------------ 296 297 298 def load_action(self, action: ActionDefinition, qname: str, 299 bound_inputs: list[str] | None = None, 300 binding_details: list[tuple[int, str]] | None = None, 301 paired_action_info: tuple | None = None): 302 """Load an action and start the preview if it's analog. 303 304 Args: 305 bound_inputs: list of input names bound to this action. 306 If any are trigger inputs (0..1 range), the X axis 307 adjusts from -1..1 to 0..1. 308 binding_details: list of (port, input_name) tuples for 309 controller input and paired axis detection. 310 paired_action_info: (ActionDefinition, qname) for the 311 paired stick axis action, or None. 312 """ 313 self._action = action 314 self._qname = qname 315 self._trail.clear() 316 self._x_motor_angle = 0.0 317 self._y_motor_angle = 0.0 318 319 # Store binding info 320 self._binding_details = binding_details or [] 321 self._primary_input_name = None 322 self._paired_input_name = None 323 self._primary_is_y = False 324 if self._binding_details: 325 _, inp = self._binding_details[0] 326 self._primary_input_name = inp 327 self._primary_is_y = inp in _Y_AXES 328 paired = STICK_PAIRS.get(inp) 329 if paired: 330 self._paired_input_name = paired 331 332 # Store paired action 333 if paired_action_info: 334 self._paired_action, self._paired_qname = paired_action_info 335 else: 336 self._paired_action = None 337 self._paired_qname = None 338 self._paired_trail.clear() 339 self._paired_1d_trail.clear() 340 self._last_paired_input = 0.0 341 self._last_paired_output = 0.0 342 343 # Detect trigger-range inputs 344 self._update_x_range(bound_inputs) 345 self._build_pipeline() 346 self._build_paired_pipeline() 347 348 if self._pipeline: 349 self._canvas.config(bg=BG_WHITE) 350 self._start_tick() 351 else: 352 self._stop_tick() 353 self._canvas.config(bg=BG_INACTIVE) 354 self._draw_inactive_message() 355 self._readout_var.set("") 356 357 def clear(self): 358 """Clear preview (no action selected).""" 359 self._action = None 360 self._qname = None 361 self._pipeline = None 362 self._slew = None 363 self._trail.clear() 364 self._binding_details = [] 365 self._primary_input_name = None 366 self._paired_action = None 367 self._paired_qname = None 368 self._paired_pipeline = None 369 self._paired_slew = None 370 self._paired_input_name = None 371 self._paired_trail.clear() 372 self._paired_1d_trail.clear() 373 self._last_paired_input = 0.0 374 self._last_paired_output = 0.0 375 self._stop_tick() 376 self._canvas.config(bg=BG_INACTIVE) 377 self._draw_inactive_message() 378 self._readout_var.set("") 379 380 def refresh(self): 381 """Rebuild pipeline from current action params (field changed).""" 382 if not self._action: 383 return 384 self._trail.clear() 385 self._paired_trail.clear() 386 self._paired_1d_trail.clear() 387 self._build_pipeline() 388 self._build_paired_pipeline() 389 if self._pipeline: 390 self._canvas.config(bg=BG_WHITE) 391 self._start_tick() 392 else: 393 self._stop_tick() 394 self._canvas.config(bg=BG_INACTIVE) 395 self._draw_inactive_message() 396 self._readout_var.set("") 397 398 def update_bindings(self, bound_inputs: list[str] | None = None, 399 binding_details: list[tuple[int, str]] | None = None, 400 paired_action_info: tuple | None = None): 401 """Update when bindings change (assign/unassign).""" 402 old_x_min = self._x_min 403 404 # Update binding details 405 self._binding_details = binding_details or [] 406 self._primary_input_name = None 407 self._paired_input_name = None 408 self._primary_is_y = False 409 if self._binding_details: 410 _, inp = self._binding_details[0] 411 self._primary_input_name = inp 412 self._primary_is_y = inp in _Y_AXES 413 paired = STICK_PAIRS.get(inp) 414 if paired: 415 self._paired_input_name = paired 416 417 # Update paired action 418 if paired_action_info: 419 self._paired_action, self._paired_qname = paired_action_info 420 else: 421 self._paired_action = None 422 self._paired_qname = None 423 self._paired_trail.clear() 424 self._paired_1d_trail.clear() 425 self._build_paired_pipeline() 426 427 self._update_x_range(bound_inputs) 428 if self._x_min != old_x_min: 429 self._trail.clear() 430 self._build_pipeline() 431 if self._pipeline: 432 self._draw() 433 434 def _update_x_range(self, bound_inputs: list[str] | None): 435 """Set X range based on bound input types. 436 437 If ALL bound inputs are triggers (0..1), use 0..1. 438 Otherwise use -1..1 (sticks, or no bindings). 439 """ 440 if bound_inputs and all( 441 inp in TRIGGER_INPUTS for inp in bound_inputs): 442 new_min = 0.0 443 else: 444 new_min = -1.0 445 446 if new_min != self._x_min: 447 self._x_min = new_min 448 # Update slider ranges 449 self._syncing_slider = True 450 self._x_slider.config(from_=new_min) 451 self._y_slider.config(from_=new_min) 452 # Clamp current value into range 453 cur = self._x_slider.get() 454 if cur < new_min: 455 self._x_slider.set(new_min) 456 self._y_slider.set(new_min) 457 self._syncing_slider = False 458 459 # ------------------------------------------------------------------ 460 # Pipeline Construction 461 # ------------------------------------------------------------------ 462 463 @staticmethod 464 def _make_pipeline_fn(action: ActionDefinition): 465 """Build a shaping closure from action parameters. 466 467 Returns (pipeline_fn, is_raw). Returns (None, False) when 468 the action is not ANALOG. 469 """ 470 if not action or action.input_type not in ( 471 InputType.ANALOG, InputType.BOOLEAN_TRIGGER): 472 return None, False 473 474 # BOOLEAN_TRIGGER: step function at threshold 475 if action.input_type == InputType.BOOLEAN_TRIGGER: 476 threshold = action.threshold 477 return (lambda raw: 1.0 if raw > threshold else 0.0), False 478 479 mode = action.trigger_mode 480 if mode == EventTriggerMode.RAW: 481 return (lambda raw: raw), True 482 483 inversion = action.inversion 484 deadband = action.deadband 485 scale = action.scale 486 extra = action.extra or {} 487 spline_pts = extra.get(EXTRA_SPLINE_POINTS) 488 segment_pts = extra.get(EXTRA_SEGMENT_POINTS) 489 490 if mode == EventTriggerMode.SQUARED: 491 def pipeline(raw): 492 v = -raw if inversion else raw 493 v = _apply_deadband(v, deadband) if deadband > 0 else v 494 v = math.copysign(v * v, v) 495 return v * scale 496 elif mode == EventTriggerMode.SPLINE and spline_pts: 497 def pipeline(raw): 498 v = -raw if inversion else raw 499 v = _apply_deadband(v, deadband) if deadband > 0 else v 500 v = evaluate_spline(spline_pts, v) 501 return v * scale 502 elif mode == EventTriggerMode.SEGMENTED and segment_pts: 503 def pipeline(raw): 504 v = -raw if inversion else raw 505 v = _apply_deadband(v, deadband) if deadband > 0 else v 506 v = evaluate_segments(segment_pts, v) 507 return v * scale 508 else: 509 # SCALED (and fallback) 510 def pipeline(raw): 511 v = -raw if inversion else raw 512 v = _apply_deadband(v, deadband) if deadband > 0 else v 513 return v * scale 514 515 return pipeline, False 516 517 @staticmethod 518 def _make_slew_limiter(action: ActionDefinition): 519 """Build a SimpleSlewLimiter from action params, or None.""" 520 slew_rate = action.slew_rate 521 if slew_rate > 0: 522 extra = action.extra or {} 523 neg_rate = extra.get(EXTRA_NEGATIVE_SLEW_RATE) 524 if neg_rate is None: 525 neg_rate = -slew_rate 526 else: 527 neg_rate = float(neg_rate) 528 return SimpleSlewLimiter( 529 slew_rate, neg_rate, dt=_TICK_MS / 1000.0) 530 return None 531 532 def _build_pipeline(self): 533 """Build the primary shaping pipeline from the current action.""" 534 self._pipeline, is_raw = self._make_pipeline_fn(self._action) 535 if self._pipeline is None: 536 self._slew = None 537 return 538 if is_raw: 539 self._y_min = -1.0 540 self._y_max = 1.0 541 self._slew = None 542 return 543 self._compute_y_range() 544 self._slew = self._make_slew_limiter(self._action) 545 546 def _build_paired_pipeline(self): 547 """Build a pipeline for the paired stick axis. 548 549 If a paired action exists, build its full shaping pipeline. 550 If no paired action but the input is a stick axis, use passthrough 551 so the 2D overlay still shows raw paired-axis values. 552 Recomputes Y range so both pipelines fit on the 1D plot. 553 """ 554 if self._paired_action: 555 self._paired_pipeline, is_raw = self._make_pipeline_fn( 556 self._paired_action) 557 if self._paired_pipeline and not is_raw: 558 self._paired_slew = self._make_slew_limiter( 559 self._paired_action) 560 else: 561 self._paired_slew = None 562 elif self._paired_input_name: 563 # Stick axis with no paired action — passthrough 564 self._paired_pipeline = lambda raw: raw 565 self._paired_slew = None 566 else: 567 self._paired_pipeline = None 568 self._paired_slew = None 569 # Recompute Y range to include both pipelines 570 if self._pipeline: 571 self._compute_y_range() 572 573 @property 574 def _show_paired(self): 575 """True when paired axis should be drawn and processed.""" 576 return (self._paired_pipeline is not None 577 and self._dual_axis_var.get()) 578 579 _Y_RANGE_SAMPLES = 200 580 581 def _compute_y_range(self): 582 """Auto-scale Y axis by sampling pipeline output across x range. 583 584 Also samples the paired pipeline so both curves fit on the plot. 585 """ 586 if not self._pipeline: 587 self._y_min = -1.0 588 self._y_max = 1.0 589 return 590 y_min = 0.0 591 y_max = 0.0 592 n = self._Y_RANGE_SAMPLES 593 x_span = 1.0 - self._x_min 594 pipelines = [self._pipeline] 595 if self._show_paired: 596 pipelines.append(self._paired_pipeline) 597 for pipe in pipelines: 598 for i in range(n + 1): 599 x = self._x_min + x_span * i / n 600 y = pipe(x) 601 y_min = min(y_min, y) 602 y_max = max(y_max, y) 603 # Ensure range always includes at least -1..1 604 y_min = min(y_min, -1.0) 605 y_max = max(y_max, 1.0) 606 pad = (y_max - y_min) * 0.05 607 self._y_min = y_min - pad 608 self._y_max = y_max + pad 609 610 @staticmethod 611 def _nice_grid_step(span: float) -> float: 612 """Choose a nice gridline step for the given data span.""" 613 return nice_grid_step(span) 614 615 # ------------------------------------------------------------------ 616 # Canvas Sizing & Coordinate Conversion 617 # ------------------------------------------------------------------ 618 619 def _on_canvas_configure(self, event): 620 w, h = event.width, event.height 621 if w < 20 or h < 20: 622 return 623 624 mx = max(25, min(50, int(w * 0.08))) 625 my = max(25, min(50, int(h * 0.08))) 626 self._margin_x = mx 627 self._margin_y = my 628 self._plot_w = max(10, w - 2 * mx) 629 self._plot_h = max(10, h - 2 * my) 630 631 if self._pipeline: 632 self._draw() 633 else: 634 self._draw_inactive_message() 635 636 def _d2c(self, x: float, y: float) -> tuple[float, float]: 637 """Data coords to canvas pixels. X uses _x_min..1, Y uses _y_min/_y_max.""" 638 x_range = 1.0 - self._x_min 639 if x_range == 0: 640 x_range = 2.0 641 cx = self._margin_x + (x - self._x_min) / x_range * self._plot_w 642 y_range = self._y_max - self._y_min 643 if y_range == 0: 644 y_range = 2.0 645 cy = self._margin_y + (self._y_max - y) / y_range * self._plot_h 646 return cx, cy 647 648 def _c2d(self, cx: float, cy: float) -> tuple[float, float]: 649 """Canvas pixel coords to data coords.""" 650 if self._plot_w == 0 or self._plot_h == 0: 651 return 0.0, 0.0 652 x_range = 1.0 - self._x_min 653 if x_range == 0: 654 x_range = 2.0 655 x = (cx - self._margin_x) / self._plot_w * x_range + self._x_min 656 y_range = self._y_max - self._y_min 657 if y_range == 0: 658 y_range = 2.0 659 y = self._y_max - (cy - self._margin_y) / self._plot_h * y_range 660 return x, y 661 662 # ------------------------------------------------------------------ 663 # Drawing 664 # ------------------------------------------------------------------ 665 666 def _draw(self): 667 """Full redraw: grid, trail, current dot.""" 668 c = self._canvas 669 c.delete("all") 670 if self._plot_w < 10: 671 return 672 673 self._draw_grid() 674 self._draw_trail() 675 self._draw_current() 676 self._draw_motors() 677 if self._show_paired: 678 self._draw_2d_overlay() 679 self._draw_legend() 680 681 def _draw_inactive_message(self): 682 """Show an inactive placeholder message.""" 683 c = self._canvas 684 c.delete("all") 685 w = c.winfo_width() 686 h = c.winfo_height() 687 if w < 10 or h < 10: 688 return 689 690 if not self._action: 691 msg = "Select an action" 692 elif self._action.input_type == InputType.BUTTON: 693 msg = "Button \u2014 no analog preview" 694 elif self._action.input_type == InputType.OUTPUT: 695 msg = "Output \u2014 no analog preview" 696 else: 697 msg = "No preview available" 698 c.create_text(w // 2, h // 2, text=msg, 699 fill="#888888", font=("TkDefaultFont", 10)) 700 701 def _draw_grid(self): 702 """Draw gridlines and axis labels (dynamic Y range).""" 703 c = self._canvas 704 mx = self._margin_x 705 my = self._margin_y 706 pw = self._plot_w 707 ph = self._plot_h 708 font = ("TkDefaultFont", 7) 709 710 # X gridlines (dynamic based on x_min) 711 if self._x_min >= 0: 712 x_vals = [0.0, 0.25, 0.5, 0.75, 1.0] 713 else: 714 x_vals = [-1.0, -0.5, 0.0, 0.5, 1.0] 715 for v in x_vals: 716 cx, _ = self._d2c(v, 0) 717 is_axis = abs(v) < 0.01 718 is_boundary = abs(v - self._x_min) < 0.01 or abs(v - 1.0) < 0.01 719 color = GRID_AXIS if is_axis else ( 720 GRID_MAJOR if is_boundary else GRID_MINOR) 721 w = 1.5 if is_axis else 1 722 c.create_line(cx, my, cx, my + ph, fill=color, width=w) 723 724 # Y gridlines (dynamic range) 725 y_step = self._nice_grid_step(self._y_max - self._y_min) 726 y_start = math.floor(self._y_min / y_step) * y_step 727 v = y_start 728 while v <= self._y_max + y_step * 0.01: 729 _, cy = self._d2c(0, v) 730 is_axis = abs(v) < y_step * 0.01 731 color = GRID_AXIS if is_axis else GRID_MAJOR 732 w = 1.5 if is_axis else 1 733 c.create_line(mx, cy, mx + pw, cy, fill=color, width=w) 734 v += y_step 735 736 # X labels (below) 737 for v in x_vals: 738 cx, _ = self._d2c(v, 0) 739 c.create_text(cx, my + ph + 12, 740 text=f"{v:g}", fill=LABEL_COLOR, font=font) 741 742 # Y labels (left, dynamic) 743 v = y_start 744 while v <= self._y_max + y_step * 0.01: 745 _, cy = self._d2c(0, v) 746 c.create_text(mx - 18, cy, 747 text=f"{v:g}", fill=LABEL_COLOR, font=font) 748 v += y_step 749 750 # Reference lines at +/-1 when Y extends beyond 751 if self._y_min < -1.05 or self._y_max > 1.05: 752 for ref_y in (-1.0, 1.0): 753 _, ry = self._d2c(0, ref_y) 754 c.create_line(mx, ry, mx + pw, ry, 755 fill="#b0b0ff", width=1, dash=(6, 3)) 756 757 # Border 758 c.create_rectangle(mx, my, mx + pw, my + ph, outline="#808080") 759 760 def _draw_trail_data(self, trail, newest_color, oldest_color): 761 """Draw a fading trail from oldest (dim) to newest (bright).""" 762 c = self._canvas 763 n = len(trail) 764 if n == 0: 765 return 766 for i, (tx, ty) in enumerate(trail): 767 frac = i / max(n - 1, 1) 768 r = int(oldest_color[0] + frac * ( 769 newest_color[0] - oldest_color[0])) 770 g = int(oldest_color[1] + frac * ( 771 newest_color[1] - oldest_color[1])) 772 b = int(oldest_color[2] + frac * ( 773 newest_color[2] - oldest_color[2])) 774 color = f"#{r:02x}{g:02x}{b:02x}" 775 cx, cy = self._d2c(tx, ty) 776 radius = 2 + frac * 2 777 c.create_oval(cx - radius, cy - radius, 778 cx + radius, cy + radius, 779 fill=color, outline="") 780 781 def _draw_trail(self): 782 """Draw fading history dots for primary and paired pipelines.""" 783 if self._show_paired: 784 if self._primary_is_y: 785 prim_new, prim_old = _Y_TRAIL_NEWEST, _Y_TRAIL_OLDEST 786 pair_new, pair_old = _X_TRAIL_NEWEST, _X_TRAIL_OLDEST 787 else: 788 prim_new, prim_old = _X_TRAIL_NEWEST, _X_TRAIL_OLDEST 789 pair_new, pair_old = _Y_TRAIL_NEWEST, _Y_TRAIL_OLDEST 790 self._draw_trail_data( 791 self._paired_1d_trail, pair_new, pair_old) 792 self._draw_trail_data(self._trail, prim_new, prim_old) 793 else: 794 self._draw_trail_data( 795 self._trail, _TRAIL_NEWEST, _TRAIL_OLDEST) 796 797 def _draw_current(self): 798 """Draw the large dot at current (input, output).""" 799 c = self._canvas 800 if self._show_paired: 801 if self._primary_is_y: 802 prim_fill, prim_out = _Y_AXIS_COLOR, _Y_AXIS_OUTLINE 803 pair_fill, pair_out = _X_AXIS_COLOR, _X_AXIS_OUTLINE 804 else: 805 prim_fill, prim_out = _X_AXIS_COLOR, _X_AXIS_OUTLINE 806 pair_fill, pair_out = _Y_AXIS_COLOR, _Y_AXIS_OUTLINE 807 # Paired dot (draw first so primary is on top) 808 px, py = self._d2c( 809 self._last_paired_input, self._last_paired_output) 810 c.create_oval(px - _DOT_RADIUS, py - _DOT_RADIUS, 811 px + _DOT_RADIUS, py + _DOT_RADIUS, 812 fill=pair_fill, outline=pair_out, width=1.5) 813 # Primary dot 814 cx, cy = self._d2c(self._last_input, self._last_output) 815 c.create_oval(cx - _DOT_RADIUS, cy - _DOT_RADIUS, 816 cx + _DOT_RADIUS, cy + _DOT_RADIUS, 817 fill=prim_fill, outline=prim_out, width=1.5) 818 else: 819 cx, cy = self._d2c(self._last_input, self._last_output) 820 c.create_oval(cx - _DOT_RADIUS, cy - _DOT_RADIUS, 821 cx + _DOT_RADIUS, cy + _DOT_RADIUS, 822 fill=CURVE_LINE, outline=_DOT_OUTLINE, width=1.5) 823 824 def _draw_motor_at(self, cx, cy, angle, label, color): 825 """Draw a spinning motor indicator at the given center position.""" 826 c = self._canvas 827 828 # Outer circle 829 c.create_oval( 830 cx - _MOTOR_RADIUS, cy - _MOTOR_RADIUS, 831 cx + _MOTOR_RADIUS, cy + _MOTOR_RADIUS, 832 outline=_MOTOR_OUTLINE, fill=_MOTOR_BG, width=1.5) 833 834 # Rotating dot on the rim 835 orbit_r = _MOTOR_RADIUS - _MOTOR_DOT_RADIUS - 2 836 dot_x = cx + orbit_r * math.cos(angle) 837 dot_y = cy + orbit_r * math.sin(angle) 838 c.create_oval( 839 dot_x - _MOTOR_DOT_RADIUS, dot_y - _MOTOR_DOT_RADIUS, 840 dot_x + _MOTOR_DOT_RADIUS, dot_y + _MOTOR_DOT_RADIUS, 841 fill=color, outline="") 842 843 # Label above motor 844 c.create_text(cx, cy - _MOTOR_RADIUS - 6, text=label, 845 fill=color, font=("TkDefaultFont", 7)) 846 847 def _draw_motors(self): 848 """Draw X and Y motor indicators when their axes are active.""" 849 mx = self._margin_x 850 my = self._margin_y 851 pw = self._plot_w 852 ph = self._plot_h 853 854 # Determine which axes have active outputs 855 both = self._show_paired 856 if self._primary_is_y: 857 y_active = self._pipeline is not None 858 x_active = both 859 else: 860 x_active = self._pipeline is not None 861 y_active = both 862 863 # Right motor (X axis) — bottom-right corner 864 if x_active: 865 rcx = mx + pw - _MOTOR_RADIUS - _MOTOR_MARGIN 866 rcy = my + ph - _MOTOR_RADIUS - _MOTOR_MARGIN 867 x_color = _X_AXIS_COLOR if both else CURVE_LINE 868 self._draw_motor_at( 869 rcx, rcy, self._x_motor_angle, "X", x_color) 870 871 # Left motor (Y axis) — bottom-left corner 872 if y_active: 873 lcx = mx + _MOTOR_RADIUS + _MOTOR_MARGIN 874 lcy = my + ph - _MOTOR_RADIUS - _MOTOR_MARGIN 875 y_color = _Y_AXIS_COLOR if both else CURVE_LINE 876 self._draw_motor_at( 877 lcx, lcy, self._y_motor_angle, "Y", y_color) 878 879 def _draw_legend(self): 880 """Draw axis color legend in top-right corner of plot.""" 881 c = self._canvas 882 mx = self._margin_x 883 my = self._margin_y 884 pw = self._plot_w 885 font = ("TkDefaultFont", 7) 886 dot_r = 3 887 line_h = 12 888 pad = 6 889 890 # Position: top-right corner, inset 891 rx = mx + pw - pad 892 ry = my + pad 893 894 # X axis entry (blue) 895 c.create_oval(rx - 40 - dot_r, ry - dot_r, 896 rx - 40 + dot_r, ry + dot_r, 897 fill=_X_AXIS_COLOR, outline="") 898 c.create_text(rx - 40 + dot_r + 4, ry, text="X axis", 899 anchor=tk.W, fill=_X_AXIS_COLOR, font=font) 900 901 # Y axis entry (red) 902 ry2 = ry + line_h 903 c.create_oval(rx - 40 - dot_r, ry2 - dot_r, 904 rx - 40 + dot_r, ry2 + dot_r, 905 fill=_Y_AXIS_COLOR, outline="") 906 c.create_text(rx - 40 + dot_r + 4, ry2, text="Y axis", 907 anchor=tk.W, fill=_Y_AXIS_COLOR, font=font) 908 909 def _draw_2d_overlay(self): 910 """Draw 2D position inset in top-left of plot area.""" 911 c = self._canvas 912 mx = self._margin_x 913 my = self._margin_y 914 size = _OVERLAY_SIZE 915 916 # Inset position: top-left corner 917 ox = mx + _OVERLAY_MARGIN 918 oy = my + _OVERLAY_MARGIN 919 920 # Background 921 c.create_rectangle(ox, oy, ox + size, oy + size, 922 fill=_OVERLAY_BG, outline=_OVERLAY_BORDER, 923 width=1) 924 925 # Crosshair at center 926 cx_center = ox + size / 2 927 cy_center = oy + size / 2 928 c.create_line(ox + 2, cy_center, ox + size - 2, cy_center, 929 fill=_OVERLAY_CROSSHAIR, width=1) 930 c.create_line(cx_center, oy + 2, cx_center, oy + size - 2, 931 fill=_OVERLAY_CROSSHAIR, width=1) 932 933 # Map -1..1 to overlay pixel coords 934 def ov_xy(xv, yv): 935 px = ox + (xv + 1.0) / 2.0 * size 936 py = oy + (1.0 - (yv + 1.0) / 2.0) * size 937 return px, py 938 939 # Determine which pipeline maps to X vs Y in the overlay 940 if self._primary_is_y: 941 x_pipe = self._paired_pipeline 942 y_pipe = self._pipeline 943 else: 944 x_pipe = self._pipeline 945 y_pipe = self._paired_pipeline 946 947 # Draw warped grid showing both pipeline responses (static, 948 # no slew). Vertical grid lines: fixed x input, sweep y. 949 # Horizontal grid lines: fixed y input, sweep x. 950 n = _OVERLAY_GRID_SAMPLES 951 grid_vals = [-1.0, -0.5, 0.0, 0.5, 1.0] 952 953 for gx in grid_vals: 954 x_out = x_pipe(gx) 955 pts = [] 956 for i in range(n + 1): 957 y_in = -1.0 + 2.0 * i / n 958 y_out = y_pipe(y_in) 959 pts.extend(ov_xy(x_out, y_out)) 960 if len(pts) >= 4: 961 c.create_line(*pts, fill=_OVERLAY_GRID_COLOR, width=1) 962 963 for gy in grid_vals: 964 y_out = y_pipe(gy) 965 pts = [] 966 for i in range(n + 1): 967 x_in = -1.0 + 2.0 * i / n 968 x_out = x_pipe(x_in) 969 pts.extend(ov_xy(x_out, y_out)) 970 if len(pts) >= 4: 971 c.create_line(*pts, fill=_OVERLAY_GRID_COLOR, width=1) 972 973 # Trail 974 n_trail = len(self._paired_trail) 975 for i, (tx, ty) in enumerate(self._paired_trail): 976 frac = i / max(n_trail - 1, 1) 977 r = int(_OVERLAY_TRAIL_OLDEST[0] + frac * ( 978 _OVERLAY_TRAIL_NEWEST[0] - _OVERLAY_TRAIL_OLDEST[0])) 979 g = int(_OVERLAY_TRAIL_OLDEST[1] + frac * ( 980 _OVERLAY_TRAIL_NEWEST[1] - _OVERLAY_TRAIL_OLDEST[1])) 981 b = int(_OVERLAY_TRAIL_OLDEST[2] + frac * ( 982 _OVERLAY_TRAIL_NEWEST[2] - _OVERLAY_TRAIL_OLDEST[2])) 983 color = f"#{r:02x}{g:02x}{b:02x}" 984 px, py = ov_xy(tx, ty) 985 radius = 1 + frac 986 c.create_oval(px - radius, py - radius, 987 px + radius, py + radius, 988 fill=color, outline="") 989 990 # Current dot — use correct physical mapping 991 if self._primary_is_y: 992 px, py = ov_xy(self._last_paired_output, self._last_output) 993 else: 994 px, py = ov_xy(self._last_output, self._last_paired_output) 995 c.create_oval( 996 px - _OVERLAY_DOT_RADIUS, py - _OVERLAY_DOT_RADIUS, 997 px + _OVERLAY_DOT_RADIUS, py + _OVERLAY_DOT_RADIUS, 998 fill=_OVERLAYCURVE_LINE, outline="") 999 1000 # Label — show stick name if known 1001 if self._primary_input_name and "left" in self._primary_input_name: 1002 label = "L Stick" 1003 elif self._primary_input_name and "right" in self._primary_input_name: 1004 label = "R Stick" 1005 else: 1006 label = "2D" 1007 c.create_text(ox + 3, oy + 3, text=label, anchor=tk.NW, 1008 fill=_OVERLAY_BORDER, font=("TkDefaultFont", 6)) 1009 1010 # ------------------------------------------------------------------ 1011 # Slider Callbacks 1012 # ------------------------------------------------------------------ 1013 1014 def _on_x_slider(self, val): 1015 """X slider changed — sync Y slider when synced.""" 1016 if self._syncing_slider: 1017 return 1018 if self._sync_var.get(): 1019 self._syncing_slider = True 1020 self._y_slider.set(float(val)) 1021 self._syncing_slider = False 1022 1023 def _on_y_slider(self, val): 1024 """Y slider changed — sync X slider when synced.""" 1025 if self._syncing_slider: 1026 return 1027 if self._sync_var.get(): 1028 self._syncing_slider = True 1029 self._x_slider.set(float(val)) 1030 self._syncing_slider = False 1031 1032 # ------------------------------------------------------------------ 1033 # Input Source Management 1034 # ------------------------------------------------------------------ 1035 1036 def _on_input_source_changed(self, event=None): 1037 """Handle input source dropdown selection.""" 1038 selection = self._input_source_var.get() 1039 if selection.startswith("Controller"): 1040 try: 1041 idx = int(selection.split()[1].rstrip(":")) 1042 self._input_mode = idx 1043 except (ValueError, IndexError): 1044 self._input_mode = "manual" 1045 else: 1046 self._input_mode = "manual" 1047 # Grey out sync checkbox on controller, enable on manual 1048 if self._input_mode == "manual": 1049 self._sync_check.state(["!disabled"]) 1050 else: 1051 self._sync_check.state(["disabled"]) 1052 # Clear trails when switching input source 1053 self._trail.clear() 1054 self._paired_trail.clear() 1055 self._paired_1d_trail.clear() 1056 if self._slew: 1057 self._slew.reset() 1058 if self._paired_slew: 1059 self._paired_slew.reset() 1060 1061 def _schedule_controller_refresh(self): 1062 """Schedule periodic controller enumeration.""" 1063 self._refresh_controller_list() 1064 self._controller_refresh_id = self.after( 1065 _CONTROLLER_REFRESH_MS, self._schedule_controller_refresh) 1066 1067 def _refresh_controller_list(self): 1068 """Update the input source dropdown with connected controllers.""" 1069 values = ["Manual (Sliders)"] 1070 if self._gamepad.available: 1071 for idx in self._gamepad.get_connected(): 1072 values.append(f"Controller {idx}") 1073 else: 1074 values.append("(Install XInput-Python for gamepad)") 1075 1076 current = self._input_source_var.get() 1077 self._input_combo["values"] = values 1078 1079 # If currently selected controller disconnected, fall back 1080 if current not in values: 1081 self._input_source_var.set("Manual (Sliders)") 1082 self._input_mode = "manual" 1083 1084 # ------------------------------------------------------------------ 1085 # Animation Loop 1086 # ------------------------------------------------------------------ 1087 1088 def _start_tick(self): 1089 """Start the animation loop if not already running.""" 1090 if self._tick_id is None: 1091 self._tick() 1092 1093 def _stop_tick(self): 1094 """Stop the animation loop.""" 1095 if self._tick_id is not None: 1096 self.after_cancel(self._tick_id) 1097 self._tick_id = None 1098 1099 def _tick(self): 1100 """One animation frame: compute output, update trail, redraw.""" 1101 if not self._pipeline: 1102 self._tick_id = None 1103 return 1104 1105 # --- Read primary input --- 1106 controller_active = ( 1107 self._input_mode != "manual" 1108 and self._primary_input_name 1109 and self._gamepad.available) 1110 1111 if controller_active: 1112 # Controller mode: read axis from gamepad 1113 raw_input = self._gamepad.get_axis( 1114 self._input_mode, self._primary_input_name) 1115 # Sync slider to show controller value. 1116 # If primary is a Y-type axis (e.g. left_stick_y), show it on 1117 # the vertical slider; otherwise on the horizontal slider. 1118 self._syncing_slider = True 1119 if self._primary_is_y: 1120 self._y_slider.set(raw_input) 1121 else: 1122 self._x_slider.set(raw_input) 1123 self._syncing_slider = False 1124 else: 1125 # Manual mode: read from X slider (primary input) 1126 raw_input = self._x_slider.get() 1127 1128 # Run through shaping pipeline 1129 shaped = self._pipeline(raw_input) 1130 1131 # Apply slew rate limiter 1132 if self._slew: 1133 output = self._slew.calculate(shaped) 1134 else: 1135 output = shaped 1136 1137 self._last_input = raw_input 1138 self._last_output = output 1139 1140 # Advance motor angles: output=1 → full speed 1141 dt = _TICK_MS / 1000.0 1142 if self._primary_is_y: 1143 self._y_motor_angle += output * _MOTOR_SPEED * dt 1144 else: 1145 self._x_motor_angle += output * _MOTOR_SPEED * dt 1146 1147 # Append to trail 1148 self._trail.append((raw_input, output)) 1149 if len(self._trail) > _TRAIL_MAX: 1150 self._trail.pop(0) 1151 1152 # --- Paired axis (2D overlay) --- 1153 if self._show_paired: 1154 if controller_active and self._paired_input_name: 1155 y_raw = self._gamepad.get_axis( 1156 self._input_mode, self._paired_input_name) 1157 # Put paired axis on the opposite slider 1158 self._syncing_slider = True 1159 if self._primary_is_y: 1160 self._x_slider.set(y_raw) 1161 else: 1162 self._y_slider.set(y_raw) 1163 self._syncing_slider = False 1164 else: 1165 y_raw = self._y_slider.get() 1166 1167 y_shaped = self._paired_pipeline(y_raw) 1168 if self._paired_slew: 1169 y_out = self._paired_slew.calculate(y_shaped) 1170 else: 1171 y_out = y_shaped 1172 self._last_paired_input = y_raw 1173 self._last_paired_output = y_out 1174 1175 # Append to 1D trail for paired pipeline 1176 self._paired_1d_trail.append((y_raw, y_out)) 1177 if len(self._paired_1d_trail) > _TRAIL_MAX: 1178 self._paired_1d_trail.pop(0) 1179 1180 # Advance the paired axis motor angle 1181 if self._primary_is_y: 1182 self._x_motor_angle += y_out * _MOTOR_SPEED * dt 1183 else: 1184 self._y_motor_angle += y_out * _MOTOR_SPEED * dt 1185 1186 # Store trail with correct physical mapping: 1187 # (x_value, y_value) where x=horizontal, y=vertical 1188 if self._primary_is_y: 1189 self._paired_trail.append((y_out, output)) 1190 else: 1191 self._paired_trail.append((output, y_out)) 1192 if len(self._paired_trail) > _OVERLAY_TRAIL_MAX: 1193 self._paired_trail.pop(0) 1194 1195 # Update readout 1196 if self._show_paired: 1197 pi = self._last_paired_input 1198 po = self._last_paired_output 1199 if self._primary_is_y: 1200 self._readout_var.set( 1201 f"X: {pi:+.3f}\u2192{po:+.3f} " 1202 f"Y: {raw_input:+.3f}\u2192{output:+.3f}") 1203 else: 1204 self._readout_var.set( 1205 f"X: {raw_input:+.3f}\u2192{output:+.3f} " 1206 f"Y: {pi:+.3f}\u2192{po:+.3f}") 1207 else: 1208 self._readout_var.set( 1209 f"In: {raw_input:+.3f} \u2192 Out: {output:+.3f}") 1210 1211 # Redraw 1212 self._draw() 1213 1214 # Schedule next tick 1215 self._tick_id = self.after(_TICK_MS, self._tick)
Interactive preview of the analog shaping pipeline.
Shows a 2-D plot with:
- X slider (horizontal) simulating the raw input (-1 to 1)
- Y slider (vertical) for paired axis / 2-axis preview
- A dot at the current (input, output) position
- A fading history trail of recent positions
- Output readout label
- Input source dropdown (Manual sliders or XInput controllers)
- 2D position overlay when paired stick axes are available
154 def __init__(self, parent): 155 super().__init__(parent) 156 157 self._action: ActionDefinition | None = None 158 self._qname: str | None = None 159 160 # Pipeline closure: float -> float (None = inactive) 161 self._pipeline = None 162 self._slew: SimpleSlewLimiter | None = None 163 164 # Canvas sizing 165 self._margin_x = 35 166 self._margin_y = 30 167 self._plot_w = 0 168 self._plot_h = 0 169 170 # X-axis range: -1..1 for sticks, 0..1 for triggers 171 self._x_min = -1.0 172 173 # Y-axis range: auto-scaled from pipeline output 174 self._y_min = -1.0 175 self._y_max = 1.0 176 177 # History trail ring buffer: list of (input_x, output_y) 178 self._trail: list[tuple[float, float]] = [] 179 180 # Animation state 181 self._tick_id = None 182 self._last_input = 0.0 183 self._last_output = 0.0 184 self._syncing_slider = False # guard against slider sync loops 185 186 # Motor visualization angles (radians) — separate for X and Y axes 187 self._x_motor_angle = 0.0 188 self._y_motor_angle = 0.0 189 190 # --- Controller input --- 191 self._gamepad = GamepadPoller() 192 self._input_mode = "manual" # "manual" or int (controller index) 193 194 # Binding details: [(port, input_name), ...] 195 self._binding_details: list[tuple[int, str]] = [] 196 # Primary binding for controller reading 197 self._primary_input_name: str | None = None 198 # True when primary axis is vertical (left_stick_y, right_stick_y) 199 # — used to swap slider/overlay mapping in controller mode 200 self._primary_is_y = False 201 202 # --- Paired axis / 2D overlay --- 203 self._paired_action: ActionDefinition | None = None 204 self._paired_qname: str | None = None 205 self._paired_pipeline = None 206 self._paired_slew: SimpleSlewLimiter | None = None 207 self._paired_input_name: str | None = None 208 self._paired_trail: list[tuple[float, float]] = [] 209 self._paired_1d_trail: list[tuple[float, float]] = [] 210 self._last_paired_input = 0.0 211 self._last_paired_output = 0.0 212 213 # Controller list refresh timer 214 self._controller_refresh_id = None 215 216 self._build_ui()
Construct a Ttk Frame with parent master.
STANDARD OPTIONS
class, cursor, style, takefocus
WIDGET-SPECIFIC OPTIONS
borderwidth, relief, padding, width, height
298 def load_action(self, action: ActionDefinition, qname: str, 299 bound_inputs: list[str] | None = None, 300 binding_details: list[tuple[int, str]] | None = None, 301 paired_action_info: tuple | None = None): 302 """Load an action and start the preview if it's analog. 303 304 Args: 305 bound_inputs: list of input names bound to this action. 306 If any are trigger inputs (0..1 range), the X axis 307 adjusts from -1..1 to 0..1. 308 binding_details: list of (port, input_name) tuples for 309 controller input and paired axis detection. 310 paired_action_info: (ActionDefinition, qname) for the 311 paired stick axis action, or None. 312 """ 313 self._action = action 314 self._qname = qname 315 self._trail.clear() 316 self._x_motor_angle = 0.0 317 self._y_motor_angle = 0.0 318 319 # Store binding info 320 self._binding_details = binding_details or [] 321 self._primary_input_name = None 322 self._paired_input_name = None 323 self._primary_is_y = False 324 if self._binding_details: 325 _, inp = self._binding_details[0] 326 self._primary_input_name = inp 327 self._primary_is_y = inp in _Y_AXES 328 paired = STICK_PAIRS.get(inp) 329 if paired: 330 self._paired_input_name = paired 331 332 # Store paired action 333 if paired_action_info: 334 self._paired_action, self._paired_qname = paired_action_info 335 else: 336 self._paired_action = None 337 self._paired_qname = None 338 self._paired_trail.clear() 339 self._paired_1d_trail.clear() 340 self._last_paired_input = 0.0 341 self._last_paired_output = 0.0 342 343 # Detect trigger-range inputs 344 self._update_x_range(bound_inputs) 345 self._build_pipeline() 346 self._build_paired_pipeline() 347 348 if self._pipeline: 349 self._canvas.config(bg=BG_WHITE) 350 self._start_tick() 351 else: 352 self._stop_tick() 353 self._canvas.config(bg=BG_INACTIVE) 354 self._draw_inactive_message() 355 self._readout_var.set("")
Load an action and start the preview if it's analog.
Args: bound_inputs: list of input names bound to this action. If any are trigger inputs (0..1 range), the X axis adjusts from -1..1 to 0..1. binding_details: list of (port, input_name) tuples for controller input and paired axis detection. paired_action_info: (ActionDefinition, qname) for the paired stick axis action, or None.
357 def clear(self): 358 """Clear preview (no action selected).""" 359 self._action = None 360 self._qname = None 361 self._pipeline = None 362 self._slew = None 363 self._trail.clear() 364 self._binding_details = [] 365 self._primary_input_name = None 366 self._paired_action = None 367 self._paired_qname = None 368 self._paired_pipeline = None 369 self._paired_slew = None 370 self._paired_input_name = None 371 self._paired_trail.clear() 372 self._paired_1d_trail.clear() 373 self._last_paired_input = 0.0 374 self._last_paired_output = 0.0 375 self._stop_tick() 376 self._canvas.config(bg=BG_INACTIVE) 377 self._draw_inactive_message() 378 self._readout_var.set("")
Clear preview (no action selected).
380 def refresh(self): 381 """Rebuild pipeline from current action params (field changed).""" 382 if not self._action: 383 return 384 self._trail.clear() 385 self._paired_trail.clear() 386 self._paired_1d_trail.clear() 387 self._build_pipeline() 388 self._build_paired_pipeline() 389 if self._pipeline: 390 self._canvas.config(bg=BG_WHITE) 391 self._start_tick() 392 else: 393 self._stop_tick() 394 self._canvas.config(bg=BG_INACTIVE) 395 self._draw_inactive_message() 396 self._readout_var.set("")
Rebuild pipeline from current action params (field changed).
398 def update_bindings(self, bound_inputs: list[str] | None = None, 399 binding_details: list[tuple[int, str]] | None = None, 400 paired_action_info: tuple | None = None): 401 """Update when bindings change (assign/unassign).""" 402 old_x_min = self._x_min 403 404 # Update binding details 405 self._binding_details = binding_details or [] 406 self._primary_input_name = None 407 self._paired_input_name = None 408 self._primary_is_y = False 409 if self._binding_details: 410 _, inp = self._binding_details[0] 411 self._primary_input_name = inp 412 self._primary_is_y = inp in _Y_AXES 413 paired = STICK_PAIRS.get(inp) 414 if paired: 415 self._paired_input_name = paired 416 417 # Update paired action 418 if paired_action_info: 419 self._paired_action, self._paired_qname = paired_action_info 420 else: 421 self._paired_action = None 422 self._paired_qname = None 423 self._paired_trail.clear() 424 self._paired_1d_trail.clear() 425 self._build_paired_pipeline() 426 427 self._update_x_range(bound_inputs) 428 if self._x_min != old_x_min: 429 self._trail.clear() 430 self._build_pipeline() 431 if self._pipeline: 432 self._draw()
Update when bindings change (assign/unassign).