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)
class SimpleSlewLimiter:
 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).

SimpleSlewLimiter(pos_rate: float, neg_rate: float, dt: float = 0.02)
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
def calculate(self, input_val: float) -> float:
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
def reset(self, value: float = 0.0):
121    def reset(self, value: float = 0.0):
122        self._value = value
class PreviewWidget(tkinter.ttk.Frame):
 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
PreviewWidget(parent)
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
def load_action( self, action: utils.controller.ActionDefinition, qname: str, bound_inputs: list[str] | None = None, binding_details: list[tuple[int, str]] | None = None, paired_action_info: tuple | None = None):
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.

def clear(self):
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).

def refresh(self):
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).

def update_bindings( self, bound_inputs: list[str] | None = None, binding_details: list[tuple[int, str]] | None = None, paired_action_info: tuple | None = None):
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).