host.controller_config.action_editor_tab

Action Editor tab with detailed panes for editing action properties.

Three-pane upper section: Common settings (left), Assigned Inputs (center), and a swappable Button/Analog options pane (right) that switches based on input type. Lower section: curve editor (left) and preview widget (right).

   1"""Action Editor tab with detailed panes for editing action properties.
   2
   3Three-pane upper section: Common settings (left), Assigned Inputs (center),
   4and a swappable Button/Analog options pane (right) that switches based on
   5input type.  Lower section: curve editor (left) and preview widget (right).
   6"""
   7
   8import tkinter as tk
   9from tkinter import ttk, messagebox
  10
  11from .action_panel import _WidgetTooltip
  12from .preview_widget import PreviewWidget
  13from .tooltips import (
  14    TIP_NAME, TIP_GROUP, TIP_DESC, TIP_INPUT_TYPE,
  15    TIP_TRIGGER_BUTTON, TIP_TRIGGER_ANALOG, TIP_THRESHOLD,
  16    TIP_DEADBAND, TIP_INVERSION, TIP_SCALE, TIP_SLEW, TIP_NEG_SLEW,
  17    TIP_ASSIGN_INPUT, TIP_ASSIGN_BTN, TIP_BOUND_LIST, TIP_UNASSIGN_BTN,
  18    TIP_VA_BUTTON_MODE, TIP_VA_RAMP_RATE, TIP_VA_ACCELERATION,
  19    TIP_VA_TARGET, TIP_VA_REST,
  20    TIP_VA_ZERO_VEL, TIP_VA_NEG_RAMP, TIP_VA_NEG_ACCEL,
  21)
  22
  23from utils.controller.model import (
  24    ANALOG_EVENT_TRIGGER_MODES,
  25    ActionDefinition,
  26    BUTTON_EVENT_TRIGGER_MODES,
  27    EXTRA_NEGATIVE_SLEW_RATE,
  28    EXTRA_SEGMENT_POINTS,
  29    EXTRA_SPLINE_POINTS,
  30    EXTRA_VA_RAMP_RATE,
  31    EXTRA_VA_ACCELERATION,
  32    EXTRA_VA_NEGATIVE_RAMP_RATE,
  33    EXTRA_VA_NEGATIVE_ACCELERATION,
  34    EXTRA_VA_ZERO_VEL_ON_RELEASE,
  35    EXTRA_VA_TARGET_VALUE,
  36    EXTRA_VA_REST_VALUE,
  37    EXTRA_VA_BUTTON_MODE,
  38    InputType,
  39    EventTriggerMode,
  40    STICK_PAIRS,
  41    validate_action_name,
  42    validate_action_group,
  43)
  44
  45
  46# ---------------------------------------------------------------------------
  47# Active/inactive pane styling
  48# ---------------------------------------------------------------------------
  49
  50_ACTIVE_FG = "#228B22"      # Forest green for active pane label
  51_INACTIVE_FG = "#999999"    # Grey for inactive pane label
  52
  53
  54def _configure_styles():
  55    """Register ttk styles for active/inactive panes (call once)."""
  56    style = ttk.Style()
  57    style.configure("Active.TLabelframe.Label", foreground=_ACTIVE_FG)
  58    style.configure("Inactive.TLabelframe.Label", foreground=_INACTIVE_FG)
  59
  60
  61def _set_children_state(widget, state: str):
  62    """Recursively set state on all interactive children of *widget*."""
  63    for child in widget.winfo_children():
  64        try:
  65            if isinstance(child, ttk.Combobox):
  66                child.config(state="readonly" if state == "normal" else state)
  67            else:
  68                child.config(state=state)
  69        except tk.TclError:
  70            pass  # Some widgets don't support the 'state' option
  71        _set_children_state(child, state)
  72
  73
  74# ---------------------------------------------------------------------------
  75# ActionEditorTab
  76# ---------------------------------------------------------------------------
  77
  78class ActionEditorTab(ttk.Frame):
  79    """Detailed action editor shown as a notebook tab.
  80
  81    Upper section has three panes: Common (left), Assigned Inputs (center),
  82    and a swappable Button/Analog options pane (right).
  83    Lower section has placeholders for future curve editor and preview.
  84    """
  85
  86    def __init__(self, parent, *,
  87                 on_before_change=None,
  88                 on_field_changed=None,
  89                 get_binding_info=None,
  90                 on_assign_action=None,
  91                 on_unassign_action=None,
  92                 get_all_controllers=None,
  93                 get_compatible_inputs=None,
  94                 is_action_bound=None,
  95                 get_all_actions=None,
  96                 get_group_names=None,
  97                 get_advanced_flags=None,
  98                 icon_loader=None):
  99        super().__init__(parent)
 100        _configure_styles()
 101
 102        self._on_before_change = on_before_change
 103        self._on_field_changed = on_field_changed
 104        self._get_all_actions = get_all_actions
 105        self._get_group_names = get_group_names
 106        self._get_advanced_flags = get_advanced_flags or (
 107            lambda: {"splines": True, "nonmono": True})
 108        self._get_binding_info = get_binding_info
 109        self._on_assign_action = on_assign_action
 110        self._on_unassign_action = on_unassign_action
 111        self._get_all_controllers = get_all_controllers
 112        self._get_compatible_inputs = get_compatible_inputs
 113        self._is_action_bound = is_action_bound
 114        self._icon_loader = icon_loader
 115        self._binding_icons: list = []  # Prevent GC of PhotoImage refs
 116
 117        self._action: ActionDefinition | None = None
 118        self._qname: str | None = None
 119        self._updating_form = False
 120        self._type_switch_active = False
 121
 122        self._assign_map: dict[str, tuple[int, str]] = {}
 123        self._bound_map: dict[str, tuple[int, str]] = {}
 124
 125        self._build_ui()
 126        self._set_all_enabled(False)
 127
 128    # ------------------------------------------------------------------
 129    # UI Construction
 130    # ------------------------------------------------------------------
 131
 132    # Minimum pane fraction (20% of total paned window dimension)
 133    _MIN_PANE_FRAC = 0.20
 134    _MIN_UPPER_H = 100   # upper section min height (px)
 135    _MIN_LOWER_H = 150   # lower section min height (px)
 136
 137    def _build_ui(self):
 138        # Top/bottom split — upper gets less weight so lower has more room
 139        self._vpaned = tk.PanedWindow(
 140            self, orient=tk.VERTICAL, sashwidth=5, sashrelief=tk.RAISED)
 141        self._vpaned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
 142
 143        # --- Upper section: 3 panes ---
 144        upper = ttk.Frame(self._vpaned)
 145        self._vpaned.add(upper, minsize=self._MIN_UPPER_H)
 146
 147        self._hpaned = tk.PanedWindow(
 148            upper, orient=tk.HORIZONTAL, sashwidth=5, sashrelief=tk.RAISED)
 149        self._hpaned.pack(fill=tk.BOTH, expand=True)
 150
 151        self._build_common_pane(self._hpaned)
 152        self._build_bindings_pane(self._hpaned)
 153        self._build_options_pane(self._hpaned)
 154
 155        # Set sash positions once the paned window is visible and sized
 156        self._saved_sash: list[int] | None = None
 157        self._sash_applied = False
 158        self._hpaned.bind("<Configure>", self._on_h_configure)
 159
 160        # --- Lower section ---
 161        lower = ttk.Frame(self._vpaned)
 162        self._vpaned.add(lower, minsize=self._MIN_LOWER_H)
 163        self._build_lower_section(lower)
 164        self._setup_tooltips()
 165
 166        # Dynamically update minsize on resize to enforce 20% minimum
 167        self._lower_paned.bind("<Configure>", self._update_lower_minsize)
 168
 169    def _setup_tooltips(self):
 170        """Attach tooltip help text to all labels and fields."""
 171        _tip = _WidgetTooltip
 172        # --- Common pane ---
 173        _tip(self._name_label, TIP_NAME)
 174        _tip(self._name_entry, TIP_NAME)
 175        _tip(self._group_label, TIP_GROUP)
 176        _tip(self._group_combo, TIP_GROUP)
 177        _tip(self._desc_label, TIP_DESC)
 178        _tip(self._desc_text, TIP_DESC)
 179        _tip(self._type_label, TIP_INPUT_TYPE)
 180        # --- Bindings pane ---
 181        _tip(self._assign_label, TIP_ASSIGN_INPUT)
 182        _tip(self._assign_combo, TIP_ASSIGN_INPUT)
 183        _tip(self._assign_btn, TIP_ASSIGN_BTN)
 184        _tip(self._bound_tree, TIP_BOUND_LIST)
 185        _tip(self._unassign_btn, TIP_UNASSIGN_BTN)
 186        # --- Button options pane ---
 187        _tip(self._btn_trigger_label, TIP_TRIGGER_BUTTON)
 188        _tip(self._btn_trigger_combo, TIP_TRIGGER_BUTTON)
 189        # --- Analog options pane ---
 190        _tip(self._analog_trigger_label, TIP_TRIGGER_ANALOG)
 191        _tip(self._analog_trigger_combo, TIP_TRIGGER_ANALOG)
 192        _tip(self._deadband_label, TIP_DEADBAND)
 193        _tip(self._deadband_spin, TIP_DEADBAND)
 194        _tip(self._inversion_label, TIP_INVERSION)
 195        _tip(self._inversion_check, TIP_INVERSION)
 196        _tip(self._scale_label, TIP_SCALE)
 197        _tip(self._scale_spin, TIP_SCALE)
 198        _tip(self._slew_label, TIP_SLEW)
 199        _tip(self._slew_spin, TIP_SLEW)
 200        _tip(self._neg_slew_check, TIP_NEG_SLEW)
 201        _tip(self._neg_slew_spin, TIP_NEG_SLEW)
 202        # --- Virtual Analog options pane ---
 203        _tip(self._va_trigger_label, TIP_TRIGGER_ANALOG)
 204        _tip(self._va_trigger_combo, TIP_TRIGGER_ANALOG)
 205
 206    def _on_h_configure(self, event):
 207        """Handle upper paned window configure: restore sash + update minsize."""
 208        pw = self._hpaned
 209        w = pw.winfo_width()
 210        if w < 50:
 211            return
 212        # Update dynamic minsize (20% of current width, capped at 30%)
 213        mn = max(80, int(w * min(self._MIN_PANE_FRAC, 0.30)))
 214        for child in (self._common_frame, self._bindings_frame,
 215                      self._options_container):
 216            try:
 217                pw.paneconfigure(child, minsize=mn)
 218            except Exception:
 219                pass  # Pane may not be mapped yet during layout
 220        # First configure: restore saved sash positions or default to 33% each
 221        # Use after_idle so minsize settles before we place sashes.
 222        if not self._sash_applied:
 223            self._sash_applied = True
 224            self.after_idle(self._apply_saved_sash, w)
 225
 226    def _apply_saved_sash(self, fallback_w):
 227        """Apply saved sash positions (deferred so minsize is settled)."""
 228        pw = self._hpaned
 229        if self._saved_sash:
 230            try:
 231                for i, pos in enumerate(self._saved_sash):
 232                    pw.sash_place(i, pos, 0)
 233                return
 234            except Exception:
 235                pass  # Saved sash positions invalid; fall through to defaults
 236        third = fallback_w // 3
 237        pw.sash_place(0, third, 0)
 238        pw.sash_place(1, third * 2, 0)
 239
 240    def set_sash_positions(self, positions: list[int]):
 241        """Store saved sash positions to apply on first configure."""
 242        self._saved_sash = positions
 243
 244    def _update_lower_minsize(self, _event=None):
 245        """Update native minsize on lower 2 panes to 28% of current width."""
 246        pw = self._lower_paned
 247        w = pw.winfo_width()
 248        if w < 50:
 249            return
 250        mn = max(120, int(w * self._MIN_PANE_FRAC))
 251        for child in pw.panes():
 252            try:
 253                pw.paneconfigure(child, minsize=mn)
 254            except Exception:
 255                pass  # Pane may not be mapped yet during layout
 256
 257    # --- Common Pane (left, compact) ---
 258
 259    def _build_common_pane(self, parent):
 260        self._common_frame = ttk.LabelFrame(
 261            parent, text="Action", padding=6)
 262        parent.add(self._common_frame, minsize=80, stretch="always")
 263
 264        self._common_frame.columnconfigure(1, weight=1)
 265
 266        row = 0
 267        # Name
 268        self._name_label = ttk.Label(
 269            self._common_frame, text="Name:", width=8)
 270        self._name_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 271        self._name_var = tk.StringVar()
 272        self._name_entry = ttk.Entry(
 273            self._common_frame, textvariable=self._name_var, width=17)
 274        self._name_entry.grid(row=row, column=1, sticky=tk.EW, pady=1)
 275        # Name commits on focus-out/Enter, not on every keystroke
 276        self._name_entry.bind("<Return>", self._commit_name_group)
 277        self._name_entry.bind("<FocusOut>", self._commit_name_group)
 278
 279        # Group
 280        row += 1
 281        self._group_label = ttk.Label(
 282            self._common_frame, text="Group:", width=8)
 283        self._group_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 284        self._group_var = tk.StringVar()
 285        self._group_combo = ttk.Combobox(
 286            self._common_frame, textvariable=self._group_var, width=15)
 287        self._group_combo.grid(row=row, column=1, sticky=tk.EW, pady=1)
 288        # Group commits on focus-out/Enter/selection, not on every keystroke
 289        self._group_combo.bind("<Return>", self._commit_name_group)
 290        self._group_combo.bind("<FocusOut>", self._commit_name_group)
 291        self._group_combo.bind("<<ComboboxSelected>>",
 292                               self._commit_name_group)
 293
 294        # Description (multi-line wrapped text)
 295        row += 1
 296        self._desc_label = ttk.Label(
 297            self._common_frame, text="Desc:", width=8)
 298        self._desc_label.grid(row=row, column=0, sticky=tk.NW, pady=1)
 299        self._desc_text = tk.Text(
 300            self._common_frame, width=1, height=3, wrap=tk.WORD,
 301            font=("TkDefaultFont", 9), relief=tk.SUNKEN, borderwidth=1)
 302        self._desc_text.grid(row=row, column=1, sticky=tk.NSEW, pady=1)
 303        self._desc_text.bind(
 304            "<<Modified>>", self._on_desc_modified)
 305
 306        # Input Type (radio buttons, compact)
 307        row += 1
 308        self._type_label = ttk.Label(
 309            self._common_frame, text="Type:", width=8)
 310        self._type_label.grid(row=row, column=0, sticky=tk.NW, pady=1)
 311        self._input_type_var = tk.StringVar()
 312        type_frame = ttk.Frame(self._common_frame)
 313        type_frame.grid(row=row, column=1, sticky=tk.W, pady=1)
 314        self._input_type_radios = []
 315        for itype in InputType:
 316            rb = ttk.Radiobutton(
 317                type_frame, text=itype.value.replace("_", " ").title(),
 318                variable=self._input_type_var, value=itype.value)
 319            rb.pack(anchor=tk.W, pady=0)
 320            self._input_type_radios.append(rb)
 321        self._input_type_var.trace_add(
 322            "write", self._on_input_type_changed_trace)
 323
 324        self._common_frame.columnconfigure(1, weight=1)
 325
 326    # --- Bindings Pane (center) ---
 327
 328    def _build_bindings_pane(self, parent):
 329        self._bindings_frame = ttk.LabelFrame(
 330            parent, text="Assigned Inputs", padding=6)
 331        parent.add(self._bindings_frame, minsize=80, stretch="always")
 332
 333        # Assign input
 334        row = 0
 335        self._assign_label = ttk.Label(
 336            self._bindings_frame, text="Assign:")
 337        self._assign_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 338        assign_frame = ttk.Frame(self._bindings_frame)
 339        assign_frame.grid(row=row, column=1, sticky=tk.EW, pady=1)
 340        self._assign_var = tk.StringVar()
 341        self._assign_combo = ttk.Combobox(
 342            assign_frame, textvariable=self._assign_var,
 343            state="readonly", width=18)
 344        self._assign_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
 345        self._assign_btn = ttk.Button(
 346            assign_frame, text="+", width=3, command=self._on_assign)
 347        self._assign_btn.pack(side=tk.LEFT, padx=(4, 0))
 348
 349        # Assigned inputs treeview (double-click to remove)
 350        row += 1
 351        style = ttk.Style()
 352        style.configure("BoundInputs.Treeview",
 353                         rowheight=26,
 354                         font=("TkDefaultFont", 10))
 355        self._bound_tree = ttk.Treeview(
 356            self._bindings_frame, height=5,
 357            selectmode="browse", show="tree",
 358            style="BoundInputs.Treeview")
 359        self._bound_tree.grid(
 360            row=row, column=0, columnspan=2, sticky=tk.NSEW, pady=(4, 2))
 361        self._bound_tree.bind("<Double-1>", lambda e: self._on_unassign())
 362        scrollbar = ttk.Scrollbar(
 363            self._bindings_frame, orient=tk.VERTICAL,
 364            command=self._bound_tree.yview)
 365        scrollbar.grid(row=row, column=2, sticky=tk.NS, pady=(4, 2))
 366        self._bound_tree.config(yscrollcommand=scrollbar.set)
 367
 368        row += 1
 369        self._unassign_btn = ttk.Button(
 370            self._bindings_frame, text="- Remove",
 371            command=self._on_unassign)
 372        self._unassign_btn.grid(row=row, column=0, sticky=tk.W, pady=1)
 373
 374        self._bindings_frame.columnconfigure(1, weight=1)
 375        self._bindings_frame.rowconfigure(1, weight=1)
 376
 377    # --- Swappable Options Pane (right) ---
 378
 379    def _build_options_pane(self, parent):
 380        """Build button and analog frames that swap in the right pane."""
 381        # Container frame that holds whichever is active
 382        self._options_container = ttk.Frame(parent)
 383        parent.add(self._options_container, minsize=80, stretch="always")
 384
 385        self._build_button_options()
 386        self._build_analog_options()
 387        self._build_va_options()
 388
 389        # Start with neither visible
 390        self._active_options = None
 391
 392    def _build_button_options(self):
 393        self._button_frame = ttk.LabelFrame(
 394            self._options_container, text="Button Options", padding=6,
 395            style="Inactive.TLabelframe")
 396
 397        row = 0
 398        self._btn_trigger_label = ttk.Label(
 399            self._button_frame, text="Trigger Mode:")
 400        self._btn_trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 401        self._btn_trigger_var = tk.StringVar()
 402        self._btn_trigger_combo = ttk.Combobox(
 403            self._button_frame, textvariable=self._btn_trigger_var,
 404            values=[m.value for m in BUTTON_EVENT_TRIGGER_MODES],
 405            state="readonly", width=18)
 406        self._btn_trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 407        self._btn_trigger_var.trace_add(
 408            "write", self._on_field_changed_trace)
 409
 410        row += 1
 411        self._threshold_label = ttk.Label(
 412            self._button_frame, text="Threshold:")
 413        self._threshold_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 414        self._threshold_var = tk.StringVar(value="0.5")
 415        self._threshold_spin = ttk.Spinbox(
 416            self._button_frame, textvariable=self._threshold_var,
 417            from_=0.0, to=1.0, increment=0.05, width=18)
 418        self._threshold_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 419        self._threshold_var.trace_add(
 420            "write", self._on_field_changed_trace)
 421        _WidgetTooltip(self._threshold_label, TIP_THRESHOLD)
 422        _WidgetTooltip(self._threshold_spin, TIP_THRESHOLD)
 423
 424        row += 1
 425        self._btn_info_label = ttk.Label(
 426            self._button_frame,
 427            text="Select a button-type action\nto edit trigger options.",
 428            foreground="#888888", justify=tk.CENTER)
 429        self._btn_info_label.grid(
 430            row=row, column=0, columnspan=2, sticky=tk.NSEW, pady=10)
 431
 432        self._button_frame.columnconfigure(1, weight=1)
 433
 434    def _build_analog_options(self):
 435        self._analog_frame = ttk.LabelFrame(
 436            self._options_container, text="Analog Options", padding=6,
 437            style="Inactive.TLabelframe")
 438
 439        row = 0
 440        self._analog_trigger_label = ttk.Label(
 441            self._analog_frame, text="Trigger Mode:")
 442        self._analog_trigger_label.grid(
 443            row=row, column=0, sticky=tk.W, pady=2)
 444        self._analog_trigger_var = tk.StringVar()
 445        self._analog_trigger_combo = ttk.Combobox(
 446            self._analog_frame, textvariable=self._analog_trigger_var,
 447            values=[m.value for m in ANALOG_EVENT_TRIGGER_MODES],
 448            state="readonly", width=18)
 449        self._analog_trigger_combo.grid(
 450            row=row, column=1, sticky=tk.EW, pady=2)
 451        self._analog_trigger_var.trace_add(
 452            "write", self._on_field_changed_trace)
 453        self._analog_trigger_var.trace_add(
 454            "write", self._check_spline_gate)
 455
 456        row += 1
 457        self._deadband_label = ttk.Label(
 458            self._analog_frame, text="Deadband:")
 459        self._deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 460        self._deadband_var = tk.StringVar(value="0.0")
 461        self._deadband_spin = ttk.Spinbox(
 462            self._analog_frame, textvariable=self._deadband_var,
 463            from_=0.0, to=1.0, increment=0.01, width=17)
 464        self._deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 465        self._deadband_var.trace_add("write", self._on_field_changed_trace)
 466
 467        row += 1
 468        self._inversion_label = ttk.Label(
 469            self._analog_frame, text="Inversion:")
 470        self._inversion_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 471        self._inversion_var = tk.BooleanVar(value=False)
 472        self._inversion_check = ttk.Checkbutton(
 473            self._analog_frame, variable=self._inversion_var)
 474        self._inversion_check.grid(row=row, column=1, sticky=tk.W, pady=2)
 475        self._inversion_var.trace_add("write", self._on_field_changed_trace)
 476
 477        row += 1
 478        self._scale_label = ttk.Label(
 479            self._analog_frame, text="Scale:")
 480        self._scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 481        self._scale_var = tk.StringVar(value="1.0")
 482        self._scale_spin = ttk.Spinbox(
 483            self._analog_frame, textvariable=self._scale_var,
 484            from_=-10.0, to=10.0, increment=0.1, width=17)
 485        self._scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 486        self._scale_var.trace_add("write", self._on_field_changed_trace)
 487
 488        row += 1
 489        self._slew_label = ttk.Label(
 490            self._analog_frame, text="Slew Rate:")
 491        self._slew_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 492        self._slew_var = tk.StringVar(value="0.0")
 493        self._slew_spin = ttk.Spinbox(
 494            self._analog_frame, textvariable=self._slew_var,
 495            from_=0.0, to=100.0, increment=0.1, width=17)
 496        self._slew_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 497        self._slew_var.trace_add("write", self._on_field_changed_trace)
 498
 499        row += 1
 500        self._neg_slew_frame = ttk.Frame(self._analog_frame)
 501        self._neg_slew_frame.grid(
 502            row=row, column=0, columnspan=2, sticky=tk.EW, pady=2)
 503        self._neg_slew_enable_var = tk.BooleanVar(value=False)
 504        self._neg_slew_check = ttk.Checkbutton(
 505            self._neg_slew_frame, text="Neg. Slew Rate:",
 506            variable=self._neg_slew_enable_var)
 507        self._neg_slew_check.pack(side=tk.LEFT)
 508        self._neg_slew_var = tk.StringVar(value="0.0")
 509        self._neg_slew_spin = ttk.Spinbox(
 510            self._neg_slew_frame, textvariable=self._neg_slew_var,
 511            from_=-100.0, to=0.0, increment=0.1, width=10)
 512        self._neg_slew_spin.pack(side=tk.LEFT, fill=tk.X, expand=True)
 513        self._neg_slew_enable_var.trace_add(
 514            "write", self._on_neg_slew_toggled)
 515        self._neg_slew_var.trace_add("write", self._on_field_changed_trace)
 516
 517        self._axis_widgets = [
 518            self._deadband_spin,
 519            self._inversion_check,
 520            self._scale_spin,
 521            self._slew_spin,
 522            self._neg_slew_check,
 523            self._neg_slew_spin,
 524        ]
 525
 526        self._analog_frame.columnconfigure(1, weight=1)
 527
 528    def _build_va_options(self):
 529        self._va_frame = ttk.LabelFrame(
 530            self._options_container, text="Virtual Analog Options", padding=6,
 531            style="Inactive.TLabelframe")
 532
 533        # Two-column layout: left = generator params, right = shaping
 534        self._va_left = ttk.Frame(self._va_frame)
 535        self._va_left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 4))
 536        self._va_right = ttk.Frame(self._va_frame)
 537        self._va_right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))
 538
 539        # --- Left column: VA generator fields ---
 540        row = 0
 541        lbl = ttk.Label(self._va_left, text="Button Mode:")
 542        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 543        self._va_mode_var = tk.StringVar(value="held")
 544        self._va_mode_combo = ttk.Combobox(
 545            self._va_left, textvariable=self._va_mode_var,
 546            values=["held", "toggle"], state="readonly", width=10)
 547        self._va_mode_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 548        self._va_mode_var.trace_add("write", self._on_field_changed_trace)
 549        _WidgetTooltip(lbl, TIP_VA_BUTTON_MODE)
 550        _WidgetTooltip(self._va_mode_combo, TIP_VA_BUTTON_MODE)
 551
 552        row += 1
 553        lbl = ttk.Label(self._va_left, text="Ramp Rate:")
 554        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 555        self._va_ramp_var = tk.StringVar(value="1.0")
 556        self._va_ramp_spin = ttk.Spinbox(
 557            self._va_left, textvariable=self._va_ramp_var,
 558            from_=0.0, to=100.0, increment=0.1, width=10)
 559        self._va_ramp_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 560        self._va_ramp_var.trace_add("write", self._on_field_changed_trace)
 561        _WidgetTooltip(lbl, TIP_VA_RAMP_RATE)
 562        _WidgetTooltip(self._va_ramp_spin, TIP_VA_RAMP_RATE)
 563
 564        row += 1
 565        lbl = ttk.Label(self._va_left, text="Acceleration:")
 566        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 567        self._va_accel_var = tk.StringVar(value="0.0")
 568        self._va_accel_spin = ttk.Spinbox(
 569            self._va_left, textvariable=self._va_accel_var,
 570            from_=0.0, to=100.0, increment=0.1, width=10)
 571        self._va_accel_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 572        self._va_accel_var.trace_add("write", self._on_field_changed_trace)
 573        _WidgetTooltip(lbl, TIP_VA_ACCELERATION)
 574        _WidgetTooltip(self._va_accel_spin, TIP_VA_ACCELERATION)
 575
 576        row += 1
 577        lbl = ttk.Label(self._va_left, text="Target:")
 578        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 579        self._va_target_var = tk.StringVar(value="1.0")
 580        self._va_target_spin = ttk.Spinbox(
 581            self._va_left, textvariable=self._va_target_var,
 582            from_=-10.0, to=10.0, increment=0.1, width=10)
 583        self._va_target_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 584        self._va_target_var.trace_add("write", self._on_field_changed_trace)
 585        _WidgetTooltip(lbl, TIP_VA_TARGET)
 586        _WidgetTooltip(self._va_target_spin, TIP_VA_TARGET)
 587
 588        row += 1
 589        lbl = ttk.Label(self._va_left, text="Rest:")
 590        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 591        self._va_rest_var = tk.StringVar(value="0.0")
 592        self._va_rest_spin = ttk.Spinbox(
 593            self._va_left, textvariable=self._va_rest_var,
 594            from_=-10.0, to=10.0, increment=0.1, width=10)
 595        self._va_rest_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 596        self._va_rest_var.trace_add("write", self._on_field_changed_trace)
 597        _WidgetTooltip(lbl, TIP_VA_REST)
 598        _WidgetTooltip(self._va_rest_spin, TIP_VA_REST)
 599
 600        row += 1
 601        self._va_zero_vel_var = tk.BooleanVar(value=False)
 602        self._va_zero_vel_check = ttk.Checkbutton(
 603            self._va_left, text="Zero vel on release",
 604            variable=self._va_zero_vel_var)
 605        self._va_zero_vel_check.grid(
 606            row=row, column=0, columnspan=2, sticky=tk.W, pady=2)
 607        self._va_zero_vel_var.trace_add(
 608            "write", self._on_field_changed_trace)
 609        _WidgetTooltip(self._va_zero_vel_check, TIP_VA_ZERO_VEL)
 610
 611        row += 1
 612        self._va_neg_ramp_enable_var = tk.BooleanVar(value=False)
 613        self._va_neg_ramp_check = ttk.Checkbutton(
 614            self._va_left, text="Neg Ramp:",
 615            variable=self._va_neg_ramp_enable_var)
 616        self._va_neg_ramp_check.grid(row=row, column=0, sticky=tk.W)
 617        self._va_neg_ramp_var = tk.StringVar(value="0.0")
 618        self._va_neg_ramp_spin = ttk.Spinbox(
 619            self._va_left, textvariable=self._va_neg_ramp_var,
 620            from_=0.0, to=100.0, increment=0.1, width=10)
 621        self._va_neg_ramp_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 622        self._va_neg_ramp_spin.config(state="disabled")
 623        self._va_neg_ramp_enable_var.trace_add(
 624            "write", self._on_va_neg_ramp_toggled)
 625        self._va_neg_ramp_var.trace_add(
 626            "write", self._on_field_changed_trace)
 627        _WidgetTooltip(self._va_neg_ramp_check, TIP_VA_NEG_RAMP)
 628        _WidgetTooltip(self._va_neg_ramp_spin, TIP_VA_NEG_RAMP)
 629
 630        row += 1
 631        self._va_neg_accel_enable_var = tk.BooleanVar(value=False)
 632        self._va_neg_accel_check = ttk.Checkbutton(
 633            self._va_left, text="Neg Accel:",
 634            variable=self._va_neg_accel_enable_var)
 635        self._va_neg_accel_check.grid(row=row, column=0, sticky=tk.W)
 636        self._va_neg_accel_var = tk.StringVar(value="0.0")
 637        self._va_neg_accel_spin = ttk.Spinbox(
 638            self._va_left, textvariable=self._va_neg_accel_var,
 639            from_=0.0, to=100.0, increment=0.1, width=10)
 640        self._va_neg_accel_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 641        self._va_neg_accel_spin.config(state="disabled")
 642        self._va_neg_accel_enable_var.trace_add(
 643            "write", self._on_va_neg_accel_toggled)
 644        self._va_neg_accel_var.trace_add(
 645            "write", self._on_field_changed_trace)
 646        _WidgetTooltip(self._va_neg_accel_check, TIP_VA_NEG_ACCEL)
 647        _WidgetTooltip(self._va_neg_accel_spin, TIP_VA_NEG_ACCEL)
 648
 649        self._va_left.columnconfigure(1, weight=1)
 650
 651        # --- Right column: trigger mode + analog shaping ---
 652        row = 0
 653        self._va_trigger_label = ttk.Label(
 654            self._va_right, text="Trigger Mode:")
 655        self._va_trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 656        self._va_trigger_var = tk.StringVar()
 657        self._va_trigger_combo = ttk.Combobox(
 658            self._va_right, textvariable=self._va_trigger_var,
 659            values=[m.value for m in ANALOG_EVENT_TRIGGER_MODES],
 660            state="readonly", width=12)
 661        self._va_trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 662        self._va_trigger_var.trace_add(
 663            "write", self._on_field_changed_trace)
 664
 665        row += 1
 666        self._va_deadband_label = ttk.Label(
 667            self._va_right, text="Deadband:")
 668        self._va_deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 669        self._va_deadband_var = tk.StringVar(value="0.0")
 670        self._va_deadband_spin = ttk.Spinbox(
 671            self._va_right, textvariable=self._va_deadband_var,
 672            from_=0.0, to=1.0, increment=0.01, width=10)
 673        self._va_deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 674        self._va_deadband_var.trace_add(
 675            "write", self._on_field_changed_trace)
 676        _WidgetTooltip(self._va_deadband_label, TIP_DEADBAND)
 677        _WidgetTooltip(self._va_deadband_spin, TIP_DEADBAND)
 678
 679        row += 1
 680        self._va_scale_label = ttk.Label(self._va_right, text="Scale:")
 681        self._va_scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 682        self._va_scale_var = tk.StringVar(value="1.0")
 683        self._va_scale_spin = ttk.Spinbox(
 684            self._va_right, textvariable=self._va_scale_var,
 685            from_=-10.0, to=10.0, increment=0.1, width=10)
 686        self._va_scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 687        self._va_scale_var.trace_add(
 688            "write", self._on_field_changed_trace)
 689        _WidgetTooltip(self._va_scale_label, TIP_SCALE)
 690        _WidgetTooltip(self._va_scale_spin, TIP_SCALE)
 691
 692        self._va_right.columnconfigure(1, weight=1)
 693
 694    def _on_va_neg_ramp_toggled(self, *args):
 695        """Enable/disable the VA negative ramp rate spinbox."""
 696        enabled = self._va_neg_ramp_enable_var.get()
 697        self._va_neg_ramp_spin.config(
 698            state="normal" if enabled else "disabled")
 699        if not self._updating_form:
 700            self._on_field_changed_trace()
 701
 702    def _on_va_neg_accel_toggled(self, *args):
 703        """Enable/disable the VA negative acceleration spinbox."""
 704        enabled = self._va_neg_accel_enable_var.get()
 705        self._va_neg_accel_spin.config(
 706            state="normal" if enabled else "disabled")
 707        if not self._updating_form:
 708            self._on_field_changed_trace()
 709
 710    # --- Lower Section Placeholders ---
 711
 712    def _build_lower_section(self, parent):
 713        from .curve_editor_widget import CurveEditorWidget
 714
 715        self._lower_paned = tk.PanedWindow(
 716            parent, orient=tk.HORIZONTAL, sashwidth=5, sashrelief=tk.RAISED)
 717        lower_paned = self._lower_paned
 718        lower_paned.pack(fill=tk.BOTH, expand=True)
 719
 720        # Left: Curve Editor
 721        curve_frame = ttk.LabelFrame(
 722            lower_paned, text="Curve Editor", padding=4)
 723        lower_paned.add(curve_frame, minsize=120)
 724
 725        self._curve_editor = CurveEditorWidget(
 726            curve_frame,
 727            on_before_change=self._on_before_change,
 728            on_curve_changed=self._on_curve_changed,
 729            get_other_curves=self._get_other_curves,
 730            get_advanced_flags=self._get_advanced_flags,
 731        )
 732        self._curve_editor.pack(fill=tk.BOTH, expand=True)
 733
 734        # Right: Preview widget
 735        preview_frame = ttk.LabelFrame(
 736            lower_paned, text="Preview", padding=4)
 737        lower_paned.add(preview_frame, minsize=120)
 738
 739        self._preview = PreviewWidget(preview_frame)
 740        self._preview.pack(fill=tk.BOTH, expand=True)
 741
 742    # ------------------------------------------------------------------
 743    # Public API
 744    # ------------------------------------------------------------------
 745
 746    def on_advanced_changed(self):
 747        """Refresh UI elements affected by Advanced menu toggles."""
 748        if self._action and self._action.input_type == InputType.ANALOG:
 749            self._update_analog_trigger_values()
 750        # Refresh curve editor toolbar (monotonic gate)
 751        self._curve_editor.on_advanced_changed()
 752
 753    def load_action(self, action: ActionDefinition, qname: str):
 754        """Populate all panes from the selected action."""
 755        self._action = action
 756        self._qname = qname
 757        self._set_all_enabled(True)
 758
 759        self._updating_form = True
 760        try:
 761            self._name_var.set(action.name)
 762            # Populate group dropdown with all known groups
 763            if self._get_group_names:
 764                self._group_combo['values'] = self._get_group_names()
 765            elif self._get_all_actions:
 766                groups = sorted({a.group
 767                                 for a in self._get_all_actions().values()})
 768                self._group_combo['values'] = groups
 769            self._group_var.set(action.group)
 770            self._desc_text.delete("1.0", tk.END)
 771            self._desc_text.insert("1.0", action.description)
 772            self._desc_text.edit_modified(False)
 773            self._input_type_var.set(action.input_type.value)
 774
 775            # Trigger mode into the correct pane
 776            if action.input_type in (InputType.BUTTON,
 777                                     InputType.BOOLEAN_TRIGGER):
 778                self._btn_trigger_var.set(action.trigger_mode.value)
 779            elif action.input_type == InputType.VIRTUAL_ANALOG:
 780                self._va_trigger_var.set(action.trigger_mode.value)
 781            else:
 782                self._analog_trigger_var.set(action.trigger_mode.value)
 783
 784            # Threshold (BOOLEAN_TRIGGER)
 785            self._threshold_var.set(str(action.threshold))
 786
 787            # Analog fields
 788            self._deadband_var.set(str(action.deadband))
 789            self._inversion_var.set(action.inversion)
 790            self._scale_var.set(str(action.scale))
 791            self._slew_var.set(str(action.slew_rate))
 792            neg_slew = action.extra.get(EXTRA_NEGATIVE_SLEW_RATE)
 793            if neg_slew is not None:
 794                self._neg_slew_enable_var.set(True)
 795                self._neg_slew_var.set(str(min(float(neg_slew), 0.0)))
 796            else:
 797                self._neg_slew_enable_var.set(False)
 798                self._neg_slew_var.set("0.0")
 799
 800            # Virtual Analog fields
 801            self._va_mode_var.set(
 802                action.extra.get(EXTRA_VA_BUTTON_MODE, "held"))
 803            self._va_ramp_var.set(
 804                str(action.extra.get(EXTRA_VA_RAMP_RATE, 0.0)))
 805            self._va_accel_var.set(
 806                str(action.extra.get(EXTRA_VA_ACCELERATION, 0.0)))
 807            self._va_target_var.set(
 808                str(action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0)))
 809            self._va_rest_var.set(
 810                str(action.extra.get(EXTRA_VA_REST_VALUE, 0.0)))
 811            self._va_zero_vel_var.set(
 812                bool(action.extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False)))
 813            neg_ramp = action.extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
 814            if neg_ramp is not None:
 815                self._va_neg_ramp_enable_var.set(True)
 816                self._va_neg_ramp_var.set(str(float(neg_ramp)))
 817            else:
 818                self._va_neg_ramp_enable_var.set(False)
 819                self._va_neg_ramp_var.set("0.0")
 820            neg_accel = action.extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
 821            if neg_accel is not None:
 822                self._va_neg_accel_enable_var.set(True)
 823                self._va_neg_accel_var.set(str(float(neg_accel)))
 824            else:
 825                self._va_neg_accel_enable_var.set(False)
 826                self._va_neg_accel_var.set("0.0")
 827            # VA pane deadband/scale mirror main action fields
 828            self._va_deadband_var.set(str(action.deadband))
 829            self._va_scale_var.set(str(action.scale))
 830
 831            self._update_pane_states()
 832        finally:
 833            self._updating_form = False
 834
 835        self._refresh_bindings()
 836        self._curve_editor.load_action(
 837            action, qname, self._get_bound_input_names())
 838        self._preview.load_action(
 839            action, qname, self._get_bound_input_names(),
 840            binding_details=self._get_binding_details(),
 841            paired_action_info=self._find_paired_analog_action())
 842
 843    def clear(self):
 844        """Clear all panes (no action selected)."""
 845        self._action = None
 846        self._qname = None
 847
 848        self._updating_form = True
 849        try:
 850            self._name_var.set("")
 851            self._group_var.set("")
 852            self._desc_text.delete("1.0", tk.END)
 853            self._desc_text.edit_modified(False)
 854            self._input_type_var.set("")
 855            self._btn_trigger_var.set("")
 856            self._analog_trigger_var.set("")
 857            self._deadband_var.set("0.0")
 858            self._inversion_var.set(False)
 859            self._scale_var.set("1.0")
 860            self._slew_var.set("0.0")
 861            self._neg_slew_enable_var.set(False)
 862            self._neg_slew_var.set("0.0")
 863            # VA fields
 864            self._va_trigger_var.set("")
 865            self._va_mode_var.set("held")
 866            self._va_ramp_var.set("0.0")
 867            self._va_accel_var.set("0.0")
 868            self._va_target_var.set("1.0")
 869            self._va_rest_var.set("0.0")
 870            self._va_zero_vel_var.set(False)
 871            self._va_neg_ramp_enable_var.set(False)
 872            self._va_neg_ramp_var.set("0.0")
 873            self._va_neg_accel_enable_var.set(False)
 874            self._va_neg_accel_var.set("0.0")
 875            self._va_deadband_var.set("0.0")
 876            self._va_scale_var.set("1.0")
 877        finally:
 878            self._updating_form = False
 879
 880        self._bound_tree.delete(*self._bound_tree.get_children())
 881        self._bound_map.clear()
 882        self._assign_combo.config(values=[])
 883        self._assign_map.clear()
 884        self._set_all_enabled(False)
 885        self._curve_editor.clear()
 886        self._preview.clear()
 887
 888    def refresh_bindings(self):
 889        """Re-query binding info for the current action."""
 890        self._refresh_bindings()
 891
 892    # ------------------------------------------------------------------
 893    # Curve Editor Callbacks
 894    # ------------------------------------------------------------------
 895
 896    def _on_curve_changed(self):
 897        """Called by CurveEditorWidget when curve data is modified."""
 898        # The curve editor may have changed action.scale (via scale handle
 899        # drag). Sync the spinbox so _save_to_action won't overwrite it.
 900        if self._action:
 901            self._updating_form = True
 902            try:
 903                self._scale_var.set(str(self._action.scale))
 904                self._threshold_var.set(str(self._action.threshold))
 905            finally:
 906                self._updating_form = False
 907        self._preview.refresh()
 908        if self._on_field_changed:
 909            self._on_field_changed()
 910
 911    def _get_other_curves(self, mode: str) -> dict[str, list[dict]]:
 912        """Return curves from other actions for 'Copy from...'."""
 913        if not self._get_all_actions:
 914            return {}
 915        key = EXTRA_SPLINE_POINTS if mode == "spline" else EXTRA_SEGMENT_POINTS
 916        all_actions = self._get_all_actions()
 917        curves = {}
 918        for qname, action in all_actions.items():
 919            if qname != self._qname:
 920                pts = action.extra.get(key)
 921                if pts:
 922                    curves[qname] = pts
 923        return curves
 924
 925    def _update_curve_editor(self):
 926        """Refresh the curve editor when action parameters change."""
 927        if self._action:
 928            self._curve_editor.load_action(
 929                self._action, self._qname,
 930                self._get_bound_input_names())
 931
 932    # ------------------------------------------------------------------
 933    # Pane State Management
 934    # ------------------------------------------------------------------
 935
 936    def _set_all_enabled(self, enabled: bool):
 937        """Enable/disable all panes."""
 938        state = "normal" if enabled else "disabled"
 939        _set_children_state(self._common_frame, state)
 940        _set_children_state(self._bindings_frame, state)
 941        _set_children_state(self._button_frame, state)
 942        _set_children_state(self._analog_frame, state)
 943        _set_children_state(self._va_frame, state)
 944        if not enabled:
 945            self._show_options_pane(None)
 946
 947    def _show_options_pane(self, which: str | None):
 948        """Swap the right pane between 'button', 'analog', 'virtual_analog', or None."""
 949        if self._active_options == which:
 950            return
 951        # Hide current
 952        if self._active_options == "button":
 953            self._button_frame.pack_forget()
 954        elif self._active_options == "analog":
 955            self._analog_frame.pack_forget()
 956        elif self._active_options == "virtual_analog":
 957            self._va_frame.pack_forget()
 958
 959        # Show new
 960        self._active_options = which
 961        if which == "button":
 962            self._button_frame.pack(fill=tk.BOTH, expand=True)
 963            self._button_frame.configure(style="Active.TLabelframe")
 964            _set_children_state(self._button_frame, "normal")
 965        elif which == "analog":
 966            self._analog_frame.pack(fill=tk.BOTH, expand=True)
 967            self._analog_frame.configure(style="Active.TLabelframe")
 968            _set_children_state(self._analog_frame, "normal")
 969        elif which == "virtual_analog":
 970            self._va_frame.pack(fill=tk.BOTH, expand=True)
 971            self._va_frame.configure(style="Active.TLabelframe")
 972            _set_children_state(self._va_frame, "normal")
 973
 974    def _update_pane_states(self):
 975        """Activate the correct options pane based on input type."""
 976        if not self._action:
 977            self._show_options_pane(None)
 978            return
 979
 980        itype = self._action.input_type
 981        is_button = itype in (InputType.BUTTON, InputType.BOOLEAN_TRIGGER)
 982        is_analog = itype == InputType.ANALOG
 983        is_va = itype == InputType.VIRTUAL_ANALOG
 984
 985        if is_button:
 986            self._show_options_pane("button")
 987            self._btn_info_label.config(text="")
 988            # Threshold: enabled for BOOLEAN_TRIGGER, disabled for BUTTON
 989            thresh_state = ("normal" if itype == InputType.BOOLEAN_TRIGGER
 990                            else "disabled")
 991            self._threshold_spin.config(state=thresh_state)
 992        elif is_analog:
 993            self._show_options_pane("analog")
 994            self._update_analog_trigger_values()
 995            self._update_raw_mode_disable()
 996            self._on_neg_slew_toggled()
 997        elif is_va:
 998            self._show_options_pane("virtual_analog")
 999            self._on_va_neg_ramp_toggled()
1000            self._on_va_neg_accel_toggled()
1001        else:
1002            # OUTPUT or other — show button pane as placeholder
1003            self._show_options_pane("button")
1004            _set_children_state(self._button_frame, "disabled")
1005            self._button_frame.configure(style="Inactive.TLabelframe")
1006            self._btn_info_label.config(
1007                text="No type-specific options\nfor this input type.")
1008
1009    _SPLINE_ADV_SUFFIX = "  (Advanced)"
1010
1011    def _update_analog_trigger_values(self):
1012        """Refresh analog trigger combo values based on advanced flags."""
1013        flags = self._get_advanced_flags()
1014        current = self._analog_trigger_var.get()
1015        values = []
1016        for m in ANALOG_EVENT_TRIGGER_MODES:
1017            label = m.value
1018            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1019                    and current != EventTriggerMode.SPLINE.value):
1020                label += self._SPLINE_ADV_SUFFIX
1021            values.append(label)
1022        self._analog_trigger_combo['values'] = values
1023
1024    def _check_spline_gate(self, *args):
1025        """Revert spline selection if splines are disabled."""
1026        if self._updating_form:
1027            return
1028        val = self._analog_trigger_var.get()
1029        if val.endswith(self._SPLINE_ADV_SUFFIX):
1030            self._updating_form = True
1031            self._analog_trigger_var.set(
1032                self._action.trigger_mode.value
1033                if self._action else EventTriggerMode.SCALED.value)
1034            self._updating_form = False
1035            messagebox.showinfo(
1036                "Advanced Feature",
1037                "Enable splines in Advanced menu to use this mode.")
1038
1039    def _update_raw_mode_disable(self):
1040        """Disable axis fields when trigger mode is RAW."""
1041        if not self._action:
1042            return
1043        if self._action.input_type not in (
1044                InputType.ANALOG, InputType.VIRTUAL_ANALOG):
1045            return
1046        trig_var = (self._va_trigger_var
1047                    if self._action.input_type == InputType.VIRTUAL_ANALOG
1048                    else self._analog_trigger_var)
1049        is_raw = trig_var.get() == EventTriggerMode.RAW.value
1050        state = "disabled" if is_raw else "normal"
1051        for w in self._axis_widgets:
1052            try:
1053                w.config(state=state)
1054            except tk.TclError:
1055                pass  # Some widgets don't support the 'state' option
1056        if not is_raw:
1057            neg_state = ("normal" if self._neg_slew_enable_var.get()
1058                         else "disabled")
1059            self._neg_slew_spin.config(state=neg_state)
1060
1061    # ------------------------------------------------------------------
1062    # Binding Management
1063    # ------------------------------------------------------------------
1064
1065    def _refresh_bindings(self):
1066        """Refresh the assigned-inputs treeview and assign dropdown."""
1067        self._bound_tree.delete(*self._bound_tree.get_children())
1068        self._bound_map.clear()
1069        self._binding_icons.clear()
1070        self._assign_combo.config(values=[])
1071        self._assign_map.clear()
1072
1073        if not self._qname:
1074            return
1075        if not (self._get_all_controllers and self._get_compatible_inputs
1076                and self._is_action_bound):
1077            return
1078
1079        controllers = self._get_all_controllers()
1080        all_inputs = self._get_compatible_inputs(self._qname)
1081
1082        # Populate assigned inputs treeview + bound_map
1083        for port, ctrl_name in controllers:
1084            for input_name, display in all_inputs:
1085                if self._is_action_bound(self._qname, port, input_name):
1086                    label = f"{ctrl_name}: {display}"
1087                    icon = None
1088                    if self._icon_loader:
1089                        icon = self._icon_loader.get_tk_icon(
1090                            input_name, 20)
1091                    kwargs = {}
1092                    if icon:
1093                        self._binding_icons.append(icon)
1094                        kwargs["image"] = icon
1095                    self._bound_tree.insert(
1096                        "", tk.END, text=label, **kwargs)
1097                    self._bound_map[label] = (port, input_name)
1098
1099        # Populate assign dropdown with compatible unbound inputs
1100        options = []
1101        for port, ctrl_name in controllers:
1102            for input_name, display in all_inputs:
1103                if not self._is_action_bound(
1104                        self._qname, port, input_name):
1105                    label = f"{ctrl_name}: {display}"
1106                    options.append(label)
1107                    self._assign_map[label] = (port, input_name)
1108        self._assign_combo.config(values=options)
1109        if options:
1110            self._assign_var.set(options[0])
1111        else:
1112            self._assign_var.set("")
1113
1114    def _get_bound_input_names(self) -> list[str]:
1115        """Return list of input names currently bound to this action."""
1116        return [inp for _, inp in self._bound_map.values()]
1117
1118    def _get_binding_details(self) -> list[tuple[int, str]]:
1119        """Return list of (port, input_name) for current action bindings."""
1120        return list(self._bound_map.values())
1121
1122    def _find_paired_analog_action(self) -> tuple | None:
1123        """Find the paired stick-axis action for 2D preview overlay.
1124
1125        Returns (ActionDefinition, qname) if a paired analog action
1126        exists, else None.
1127        """
1128        if not self._bound_map or not self._get_all_actions:
1129            return None
1130        # Find first stick binding
1131        primary_port = None
1132        paired_input = None
1133        for _label, (port, input_name) in self._bound_map.items():
1134            paired = STICK_PAIRS.get(input_name)
1135            if paired:
1136                primary_port = port
1137                paired_input = paired
1138                break
1139        if not paired_input:
1140            return None
1141        # Search all actions for one bound to paired_input on same port
1142        all_actions = self._get_all_actions()
1143        for qname, action in all_actions.items():
1144            if qname == self._qname:
1145                continue
1146            if (action.input_type == InputType.ANALOG
1147                    and self._is_action_bound
1148                    and self._is_action_bound(
1149                        qname, primary_port, paired_input)):
1150                return (action, qname)
1151        return None
1152
1153    def _on_assign(self):
1154        """Assign the selected input to the current action."""
1155        if not self._qname:
1156            return
1157        label = self._assign_var.get()
1158        mapping = self._assign_map.get(label)
1159        if not mapping:
1160            return
1161        port, input_name = mapping
1162        if self._on_before_change:
1163            self._on_before_change(0)
1164        if self._on_assign_action:
1165            self._on_assign_action(self._qname, port, input_name)
1166        self._refresh_bindings()
1167        bound = self._get_bound_input_names()
1168        self._curve_editor.update_bindings(bound)
1169        self._preview.update_bindings(
1170            bound,
1171            binding_details=self._get_binding_details(),
1172            paired_action_info=self._find_paired_analog_action())
1173        if self._on_field_changed:
1174            self._on_field_changed()
1175
1176    def _on_unassign(self):
1177        """Remove the selected binding from the current action."""
1178        if not self._qname:
1179            return
1180        sel = self._bound_tree.selection()
1181        if not sel:
1182            return
1183        label = self._bound_tree.item(sel[0], "text")
1184        mapping = self._bound_map.get(label)
1185        if not mapping:
1186            return
1187        port, input_name = mapping
1188        if self._on_before_change:
1189            self._on_before_change(0)
1190        if self._on_unassign_action:
1191            self._on_unassign_action(self._qname, port, input_name)
1192        self._refresh_bindings()
1193        bound = self._get_bound_input_names()
1194        self._curve_editor.update_bindings(bound)
1195        self._preview.update_bindings(
1196            bound,
1197            binding_details=self._get_binding_details(),
1198            paired_action_info=self._find_paired_analog_action())
1199        if self._on_field_changed:
1200            self._on_field_changed()
1201
1202    # ------------------------------------------------------------------
1203    # Field Change Handlers
1204    # ------------------------------------------------------------------
1205
1206    def _on_field_changed_trace(self, *args):
1207        """Trace callback for variable writes."""
1208        if self._updating_form or not self._action:
1209            return
1210        self._save_to_action()
1211        # Refresh spline gate after trigger mode change
1212        if self._action and self._action.input_type == InputType.ANALOG:
1213            self._update_analog_trigger_values()
1214        self._update_curve_editor()
1215        self._preview.refresh()
1216        if self._on_field_changed:
1217            self._on_field_changed()
1218
1219    def _set_field_error(self, widget, has_error: bool):
1220        """Apply or clear error styling on a ttk Entry or Combobox."""
1221        base = widget.winfo_class()
1222        widget.configure(style=f"Error.{base}" if has_error else f"{base}")
1223
1224    def _flash_field_warning(self, widget, err: str):
1225        """Flash error styling and show a warning for an invalid field."""
1226        if getattr(self, '_showing_warning', False):
1227            return
1228        self._showing_warning = True
1229        self._set_field_error(widget, True)
1230        messagebox.showwarning("Invalid Value", err)
1231        self._set_field_error(widget, False)
1232        self._showing_warning = False
1233
1234    def _commit_name_group(self, event=None):
1235        """Commit name/group changes on focus-out or Enter."""
1236        if self._updating_form or not self._action:
1237            return
1238
1239        action = self._action
1240        new_name = self._name_var.get().strip()
1241        new_group = self._group_var.get().strip()
1242        changed = False
1243
1244        # Validate and apply name
1245        if new_name and new_name != action.name:
1246            err = validate_action_name(new_name)
1247            if err:
1248                self._flash_field_warning(self._name_entry, err)
1249                self._updating_form = True
1250                self._name_var.set(action.name)
1251                self._updating_form = False
1252            else:
1253                action.name = new_name
1254                changed = True
1255        elif not new_name:
1256            self._flash_field_warning(
1257                self._name_entry, "Name cannot be empty.")
1258            self._updating_form = True
1259            self._name_var.set(action.name)
1260            self._updating_form = False
1261        self._set_field_error(self._name_entry, False)
1262
1263        # Validate and apply group
1264        if new_group and new_group != action.group:
1265            err = validate_action_group(new_group)
1266            if err:
1267                self._flash_field_warning(self._group_combo, err)
1268                self._updating_form = True
1269                self._group_var.set(action.group)
1270                self._updating_form = False
1271            else:
1272                action.group = new_group
1273                changed = True
1274        elif not new_group:
1275            self._flash_field_warning(
1276                self._group_combo, "Group cannot be empty.")
1277            self._updating_form = True
1278            self._group_var.set(action.group)
1279            self._updating_form = False
1280        self._set_field_error(self._group_combo, False)
1281
1282        if changed and self._on_field_changed:
1283            self._on_field_changed()
1284
1285    def _on_desc_modified(self, event=None):
1286        """Handle description Text widget changes."""
1287        if not self._desc_text.edit_modified():
1288            return
1289        self._desc_text.edit_modified(False)
1290        if self._updating_form or not self._action:
1291            return
1292        self._save_to_action()
1293        if self._on_field_changed:
1294            self._on_field_changed()
1295
1296    def _on_input_type_changed_trace(self, *args):
1297        """Handle input type changes with warning and pane switching."""
1298        if self._updating_form or not self._action:
1299            return
1300
1301        new_type_str = self._input_type_var.get()
1302        if not new_type_str:
1303            return
1304        try:
1305            new_type = InputType(new_type_str)
1306        except ValueError:
1307            return
1308
1309        if new_type == self._action.input_type:
1310            return
1311
1312        # Warn if action has custom settings
1313        if getattr(self._action, '_has_custom', False):
1314            if not messagebox.askyesno(
1315                "Change Input Type",
1316                "Changing input type may reset or\n"
1317                "invalidate current settings (deadband,\n"
1318                "scale, curves, bindings). Continue?",
1319            ):
1320                # Defer revert — setting a var inside its own trace is unreliable
1321                old_val = self._action.input_type.value
1322                self.after_idle(self._revert_input_type, old_val)
1323                return
1324
1325        if self._on_before_change:
1326            self._on_before_change(0)
1327
1328        self._type_switch_active = True
1329        try:
1330            self._action.input_type = new_type
1331
1332            if new_type == InputType.ANALOG:
1333                if self._action.trigger_mode in BUTTON_EVENT_TRIGGER_MODES:
1334                    self._action.trigger_mode = EventTriggerMode.SCALED
1335                if self._action.deadband < 0.01:
1336                    self._action.deadband = 0.05
1337            elif new_type in (InputType.BUTTON, InputType.BOOLEAN_TRIGGER):
1338                if self._action.trigger_mode in ANALOG_EVENT_TRIGGER_MODES:
1339                    self._action.trigger_mode = EventTriggerMode.ON_TRUE
1340
1341            self.load_action(self._action, self._qname)
1342        finally:
1343            self._type_switch_active = False
1344
1345        self._action._has_custom = False
1346        if self._on_field_changed:
1347            self._on_field_changed()
1348
1349    def _revert_input_type(self, old_val):
1350        """Revert the input type radio after user cancelled type switch."""
1351        self._updating_form = True
1352        try:
1353            self._input_type_var.set(old_val)
1354        finally:
1355            self._updating_form = False
1356
1357    def _on_neg_slew_toggled(self, *args):
1358        """Enable/disable the negative slew rate spinbox."""
1359        if self._action and self._action.input_type == InputType.ANALOG:
1360            is_raw = (self._analog_trigger_var.get()
1361                      == EventTriggerMode.RAW.value)
1362            if not is_raw:
1363                enabled = self._neg_slew_enable_var.get()
1364                self._neg_slew_spin.config(
1365                    state="normal" if enabled else "disabled")
1366        if not self._updating_form:
1367            self._on_field_changed_trace()
1368
1369    def _save_to_action(self):
1370        """Write current form values back to the ActionDefinition."""
1371        action = self._action
1372        if not action:
1373            return
1374
1375        if self._on_before_change:
1376            self._on_before_change(200)
1377
1378        action.description = self._desc_text.get("1.0", "end-1c").strip()
1379        # Name and group are committed on focus-out/Enter, not on every
1380        # keystroke — see _commit_name_group().
1381
1382        # Trigger mode from the active pane
1383        if action.input_type in (InputType.BUTTON,
1384                                 InputType.BOOLEAN_TRIGGER):
1385            trigger_str = self._btn_trigger_var.get()
1386        elif action.input_type == InputType.VIRTUAL_ANALOG:
1387            trigger_str = self._va_trigger_var.get()
1388        else:
1389            trigger_str = self._analog_trigger_var.get()
1390        # Strip the "(Advanced)" suffix if present (user should not be able
1391        # to select it, but guard against it).
1392        if trigger_str and trigger_str.endswith(self._SPLINE_ADV_SUFFIX):
1393            trigger_str = ""
1394        if trigger_str:
1395            try:
1396                action.trigger_mode = EventTriggerMode(trigger_str)
1397            except ValueError:
1398                pass  # Invalid trigger mode string; keep previous value
1399
1400        # Threshold (BOOLEAN_TRIGGER)
1401        if action.input_type == InputType.BOOLEAN_TRIGGER:
1402            try:
1403                action.threshold = float(
1404                    self._threshold_var.get() or 0.5)
1405            except ValueError:
1406                pass  # Invalid input; keep previous threshold
1407
1408        # Analog fields
1409        try:
1410            action.deadband = float(self._deadband_var.get() or 0.0)
1411        except ValueError:
1412            pass  # Invalid input; keep previous deadband value
1413        action.inversion = self._inversion_var.get()
1414        try:
1415            action.scale = float(self._scale_var.get() or 1.0)
1416        except ValueError:
1417            pass  # Invalid input; keep previous scale value
1418        try:
1419            action.slew_rate = float(self._slew_var.get() or 0.0)
1420        except ValueError:
1421            pass  # Invalid input; keep previous slew rate value
1422
1423        if self._neg_slew_enable_var.get():
1424            try:
1425                val = float(self._neg_slew_var.get() or 0.0)
1426                action.extra[EXTRA_NEGATIVE_SLEW_RATE] = min(val, 0.0)
1427            except ValueError:
1428                pass  # Invalid input; keep previous negative slew value
1429        else:
1430            action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1431
1432        # Virtual Analog extra fields
1433        if action.input_type == InputType.VIRTUAL_ANALOG:
1434            try:
1435                action.extra[EXTRA_VA_BUTTON_MODE] = (
1436                    self._va_mode_var.get() or "held")
1437                action.extra[EXTRA_VA_RAMP_RATE] = float(
1438                    self._va_ramp_var.get() or 0.0)
1439                action.extra[EXTRA_VA_ACCELERATION] = float(
1440                    self._va_accel_var.get() or 0.0)
1441                action.extra[EXTRA_VA_TARGET_VALUE] = float(
1442                    self._va_target_var.get() or 1.0)
1443                action.extra[EXTRA_VA_REST_VALUE] = float(
1444                    self._va_rest_var.get() or 0.0)
1445                action.extra[EXTRA_VA_ZERO_VEL_ON_RELEASE] = bool(
1446                    self._va_zero_vel_var.get())
1447                if self._va_neg_ramp_enable_var.get():
1448                    action.extra[EXTRA_VA_NEGATIVE_RAMP_RATE] = float(
1449                        self._va_neg_ramp_var.get() or 0.0)
1450                else:
1451                    action.extra.pop(EXTRA_VA_NEGATIVE_RAMP_RATE, None)
1452                if self._va_neg_accel_enable_var.get():
1453                    action.extra[EXTRA_VA_NEGATIVE_ACCELERATION] = float(
1454                        self._va_neg_accel_var.get() or 0.0)
1455                else:
1456                    action.extra.pop(EXTRA_VA_NEGATIVE_ACCELERATION, None)
1457                # VA pane has its own deadband/scale spinboxes
1458                action.deadband = float(
1459                    self._va_deadband_var.get() or 0.0)
1460                action.scale = float(
1461                    self._va_scale_var.get() or 1.0)
1462            except ValueError:
1463                pass  # Non-numeric entry; keep previous values
1464
1465        if not self._type_switch_active:
1466            action._has_custom = True
1467
1468        self._update_raw_mode_disable()
class ActionEditorTab(tkinter.ttk.Frame):
  79class ActionEditorTab(ttk.Frame):
  80    """Detailed action editor shown as a notebook tab.
  81
  82    Upper section has three panes: Common (left), Assigned Inputs (center),
  83    and a swappable Button/Analog options pane (right).
  84    Lower section has placeholders for future curve editor and preview.
  85    """
  86
  87    def __init__(self, parent, *,
  88                 on_before_change=None,
  89                 on_field_changed=None,
  90                 get_binding_info=None,
  91                 on_assign_action=None,
  92                 on_unassign_action=None,
  93                 get_all_controllers=None,
  94                 get_compatible_inputs=None,
  95                 is_action_bound=None,
  96                 get_all_actions=None,
  97                 get_group_names=None,
  98                 get_advanced_flags=None,
  99                 icon_loader=None):
 100        super().__init__(parent)
 101        _configure_styles()
 102
 103        self._on_before_change = on_before_change
 104        self._on_field_changed = on_field_changed
 105        self._get_all_actions = get_all_actions
 106        self._get_group_names = get_group_names
 107        self._get_advanced_flags = get_advanced_flags or (
 108            lambda: {"splines": True, "nonmono": True})
 109        self._get_binding_info = get_binding_info
 110        self._on_assign_action = on_assign_action
 111        self._on_unassign_action = on_unassign_action
 112        self._get_all_controllers = get_all_controllers
 113        self._get_compatible_inputs = get_compatible_inputs
 114        self._is_action_bound = is_action_bound
 115        self._icon_loader = icon_loader
 116        self._binding_icons: list = []  # Prevent GC of PhotoImage refs
 117
 118        self._action: ActionDefinition | None = None
 119        self._qname: str | None = None
 120        self._updating_form = False
 121        self._type_switch_active = False
 122
 123        self._assign_map: dict[str, tuple[int, str]] = {}
 124        self._bound_map: dict[str, tuple[int, str]] = {}
 125
 126        self._build_ui()
 127        self._set_all_enabled(False)
 128
 129    # ------------------------------------------------------------------
 130    # UI Construction
 131    # ------------------------------------------------------------------
 132
 133    # Minimum pane fraction (20% of total paned window dimension)
 134    _MIN_PANE_FRAC = 0.20
 135    _MIN_UPPER_H = 100   # upper section min height (px)
 136    _MIN_LOWER_H = 150   # lower section min height (px)
 137
 138    def _build_ui(self):
 139        # Top/bottom split — upper gets less weight so lower has more room
 140        self._vpaned = tk.PanedWindow(
 141            self, orient=tk.VERTICAL, sashwidth=5, sashrelief=tk.RAISED)
 142        self._vpaned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
 143
 144        # --- Upper section: 3 panes ---
 145        upper = ttk.Frame(self._vpaned)
 146        self._vpaned.add(upper, minsize=self._MIN_UPPER_H)
 147
 148        self._hpaned = tk.PanedWindow(
 149            upper, orient=tk.HORIZONTAL, sashwidth=5, sashrelief=tk.RAISED)
 150        self._hpaned.pack(fill=tk.BOTH, expand=True)
 151
 152        self._build_common_pane(self._hpaned)
 153        self._build_bindings_pane(self._hpaned)
 154        self._build_options_pane(self._hpaned)
 155
 156        # Set sash positions once the paned window is visible and sized
 157        self._saved_sash: list[int] | None = None
 158        self._sash_applied = False
 159        self._hpaned.bind("<Configure>", self._on_h_configure)
 160
 161        # --- Lower section ---
 162        lower = ttk.Frame(self._vpaned)
 163        self._vpaned.add(lower, minsize=self._MIN_LOWER_H)
 164        self._build_lower_section(lower)
 165        self._setup_tooltips()
 166
 167        # Dynamically update minsize on resize to enforce 20% minimum
 168        self._lower_paned.bind("<Configure>", self._update_lower_minsize)
 169
 170    def _setup_tooltips(self):
 171        """Attach tooltip help text to all labels and fields."""
 172        _tip = _WidgetTooltip
 173        # --- Common pane ---
 174        _tip(self._name_label, TIP_NAME)
 175        _tip(self._name_entry, TIP_NAME)
 176        _tip(self._group_label, TIP_GROUP)
 177        _tip(self._group_combo, TIP_GROUP)
 178        _tip(self._desc_label, TIP_DESC)
 179        _tip(self._desc_text, TIP_DESC)
 180        _tip(self._type_label, TIP_INPUT_TYPE)
 181        # --- Bindings pane ---
 182        _tip(self._assign_label, TIP_ASSIGN_INPUT)
 183        _tip(self._assign_combo, TIP_ASSIGN_INPUT)
 184        _tip(self._assign_btn, TIP_ASSIGN_BTN)
 185        _tip(self._bound_tree, TIP_BOUND_LIST)
 186        _tip(self._unassign_btn, TIP_UNASSIGN_BTN)
 187        # --- Button options pane ---
 188        _tip(self._btn_trigger_label, TIP_TRIGGER_BUTTON)
 189        _tip(self._btn_trigger_combo, TIP_TRIGGER_BUTTON)
 190        # --- Analog options pane ---
 191        _tip(self._analog_trigger_label, TIP_TRIGGER_ANALOG)
 192        _tip(self._analog_trigger_combo, TIP_TRIGGER_ANALOG)
 193        _tip(self._deadband_label, TIP_DEADBAND)
 194        _tip(self._deadband_spin, TIP_DEADBAND)
 195        _tip(self._inversion_label, TIP_INVERSION)
 196        _tip(self._inversion_check, TIP_INVERSION)
 197        _tip(self._scale_label, TIP_SCALE)
 198        _tip(self._scale_spin, TIP_SCALE)
 199        _tip(self._slew_label, TIP_SLEW)
 200        _tip(self._slew_spin, TIP_SLEW)
 201        _tip(self._neg_slew_check, TIP_NEG_SLEW)
 202        _tip(self._neg_slew_spin, TIP_NEG_SLEW)
 203        # --- Virtual Analog options pane ---
 204        _tip(self._va_trigger_label, TIP_TRIGGER_ANALOG)
 205        _tip(self._va_trigger_combo, TIP_TRIGGER_ANALOG)
 206
 207    def _on_h_configure(self, event):
 208        """Handle upper paned window configure: restore sash + update minsize."""
 209        pw = self._hpaned
 210        w = pw.winfo_width()
 211        if w < 50:
 212            return
 213        # Update dynamic minsize (20% of current width, capped at 30%)
 214        mn = max(80, int(w * min(self._MIN_PANE_FRAC, 0.30)))
 215        for child in (self._common_frame, self._bindings_frame,
 216                      self._options_container):
 217            try:
 218                pw.paneconfigure(child, minsize=mn)
 219            except Exception:
 220                pass  # Pane may not be mapped yet during layout
 221        # First configure: restore saved sash positions or default to 33% each
 222        # Use after_idle so minsize settles before we place sashes.
 223        if not self._sash_applied:
 224            self._sash_applied = True
 225            self.after_idle(self._apply_saved_sash, w)
 226
 227    def _apply_saved_sash(self, fallback_w):
 228        """Apply saved sash positions (deferred so minsize is settled)."""
 229        pw = self._hpaned
 230        if self._saved_sash:
 231            try:
 232                for i, pos in enumerate(self._saved_sash):
 233                    pw.sash_place(i, pos, 0)
 234                return
 235            except Exception:
 236                pass  # Saved sash positions invalid; fall through to defaults
 237        third = fallback_w // 3
 238        pw.sash_place(0, third, 0)
 239        pw.sash_place(1, third * 2, 0)
 240
 241    def set_sash_positions(self, positions: list[int]):
 242        """Store saved sash positions to apply on first configure."""
 243        self._saved_sash = positions
 244
 245    def _update_lower_minsize(self, _event=None):
 246        """Update native minsize on lower 2 panes to 28% of current width."""
 247        pw = self._lower_paned
 248        w = pw.winfo_width()
 249        if w < 50:
 250            return
 251        mn = max(120, int(w * self._MIN_PANE_FRAC))
 252        for child in pw.panes():
 253            try:
 254                pw.paneconfigure(child, minsize=mn)
 255            except Exception:
 256                pass  # Pane may not be mapped yet during layout
 257
 258    # --- Common Pane (left, compact) ---
 259
 260    def _build_common_pane(self, parent):
 261        self._common_frame = ttk.LabelFrame(
 262            parent, text="Action", padding=6)
 263        parent.add(self._common_frame, minsize=80, stretch="always")
 264
 265        self._common_frame.columnconfigure(1, weight=1)
 266
 267        row = 0
 268        # Name
 269        self._name_label = ttk.Label(
 270            self._common_frame, text="Name:", width=8)
 271        self._name_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 272        self._name_var = tk.StringVar()
 273        self._name_entry = ttk.Entry(
 274            self._common_frame, textvariable=self._name_var, width=17)
 275        self._name_entry.grid(row=row, column=1, sticky=tk.EW, pady=1)
 276        # Name commits on focus-out/Enter, not on every keystroke
 277        self._name_entry.bind("<Return>", self._commit_name_group)
 278        self._name_entry.bind("<FocusOut>", self._commit_name_group)
 279
 280        # Group
 281        row += 1
 282        self._group_label = ttk.Label(
 283            self._common_frame, text="Group:", width=8)
 284        self._group_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 285        self._group_var = tk.StringVar()
 286        self._group_combo = ttk.Combobox(
 287            self._common_frame, textvariable=self._group_var, width=15)
 288        self._group_combo.grid(row=row, column=1, sticky=tk.EW, pady=1)
 289        # Group commits on focus-out/Enter/selection, not on every keystroke
 290        self._group_combo.bind("<Return>", self._commit_name_group)
 291        self._group_combo.bind("<FocusOut>", self._commit_name_group)
 292        self._group_combo.bind("<<ComboboxSelected>>",
 293                               self._commit_name_group)
 294
 295        # Description (multi-line wrapped text)
 296        row += 1
 297        self._desc_label = ttk.Label(
 298            self._common_frame, text="Desc:", width=8)
 299        self._desc_label.grid(row=row, column=0, sticky=tk.NW, pady=1)
 300        self._desc_text = tk.Text(
 301            self._common_frame, width=1, height=3, wrap=tk.WORD,
 302            font=("TkDefaultFont", 9), relief=tk.SUNKEN, borderwidth=1)
 303        self._desc_text.grid(row=row, column=1, sticky=tk.NSEW, pady=1)
 304        self._desc_text.bind(
 305            "<<Modified>>", self._on_desc_modified)
 306
 307        # Input Type (radio buttons, compact)
 308        row += 1
 309        self._type_label = ttk.Label(
 310            self._common_frame, text="Type:", width=8)
 311        self._type_label.grid(row=row, column=0, sticky=tk.NW, pady=1)
 312        self._input_type_var = tk.StringVar()
 313        type_frame = ttk.Frame(self._common_frame)
 314        type_frame.grid(row=row, column=1, sticky=tk.W, pady=1)
 315        self._input_type_radios = []
 316        for itype in InputType:
 317            rb = ttk.Radiobutton(
 318                type_frame, text=itype.value.replace("_", " ").title(),
 319                variable=self._input_type_var, value=itype.value)
 320            rb.pack(anchor=tk.W, pady=0)
 321            self._input_type_radios.append(rb)
 322        self._input_type_var.trace_add(
 323            "write", self._on_input_type_changed_trace)
 324
 325        self._common_frame.columnconfigure(1, weight=1)
 326
 327    # --- Bindings Pane (center) ---
 328
 329    def _build_bindings_pane(self, parent):
 330        self._bindings_frame = ttk.LabelFrame(
 331            parent, text="Assigned Inputs", padding=6)
 332        parent.add(self._bindings_frame, minsize=80, stretch="always")
 333
 334        # Assign input
 335        row = 0
 336        self._assign_label = ttk.Label(
 337            self._bindings_frame, text="Assign:")
 338        self._assign_label.grid(row=row, column=0, sticky=tk.W, pady=1)
 339        assign_frame = ttk.Frame(self._bindings_frame)
 340        assign_frame.grid(row=row, column=1, sticky=tk.EW, pady=1)
 341        self._assign_var = tk.StringVar()
 342        self._assign_combo = ttk.Combobox(
 343            assign_frame, textvariable=self._assign_var,
 344            state="readonly", width=18)
 345        self._assign_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
 346        self._assign_btn = ttk.Button(
 347            assign_frame, text="+", width=3, command=self._on_assign)
 348        self._assign_btn.pack(side=tk.LEFT, padx=(4, 0))
 349
 350        # Assigned inputs treeview (double-click to remove)
 351        row += 1
 352        style = ttk.Style()
 353        style.configure("BoundInputs.Treeview",
 354                         rowheight=26,
 355                         font=("TkDefaultFont", 10))
 356        self._bound_tree = ttk.Treeview(
 357            self._bindings_frame, height=5,
 358            selectmode="browse", show="tree",
 359            style="BoundInputs.Treeview")
 360        self._bound_tree.grid(
 361            row=row, column=0, columnspan=2, sticky=tk.NSEW, pady=(4, 2))
 362        self._bound_tree.bind("<Double-1>", lambda e: self._on_unassign())
 363        scrollbar = ttk.Scrollbar(
 364            self._bindings_frame, orient=tk.VERTICAL,
 365            command=self._bound_tree.yview)
 366        scrollbar.grid(row=row, column=2, sticky=tk.NS, pady=(4, 2))
 367        self._bound_tree.config(yscrollcommand=scrollbar.set)
 368
 369        row += 1
 370        self._unassign_btn = ttk.Button(
 371            self._bindings_frame, text="- Remove",
 372            command=self._on_unassign)
 373        self._unassign_btn.grid(row=row, column=0, sticky=tk.W, pady=1)
 374
 375        self._bindings_frame.columnconfigure(1, weight=1)
 376        self._bindings_frame.rowconfigure(1, weight=1)
 377
 378    # --- Swappable Options Pane (right) ---
 379
 380    def _build_options_pane(self, parent):
 381        """Build button and analog frames that swap in the right pane."""
 382        # Container frame that holds whichever is active
 383        self._options_container = ttk.Frame(parent)
 384        parent.add(self._options_container, minsize=80, stretch="always")
 385
 386        self._build_button_options()
 387        self._build_analog_options()
 388        self._build_va_options()
 389
 390        # Start with neither visible
 391        self._active_options = None
 392
 393    def _build_button_options(self):
 394        self._button_frame = ttk.LabelFrame(
 395            self._options_container, text="Button Options", padding=6,
 396            style="Inactive.TLabelframe")
 397
 398        row = 0
 399        self._btn_trigger_label = ttk.Label(
 400            self._button_frame, text="Trigger Mode:")
 401        self._btn_trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 402        self._btn_trigger_var = tk.StringVar()
 403        self._btn_trigger_combo = ttk.Combobox(
 404            self._button_frame, textvariable=self._btn_trigger_var,
 405            values=[m.value for m in BUTTON_EVENT_TRIGGER_MODES],
 406            state="readonly", width=18)
 407        self._btn_trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 408        self._btn_trigger_var.trace_add(
 409            "write", self._on_field_changed_trace)
 410
 411        row += 1
 412        self._threshold_label = ttk.Label(
 413            self._button_frame, text="Threshold:")
 414        self._threshold_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 415        self._threshold_var = tk.StringVar(value="0.5")
 416        self._threshold_spin = ttk.Spinbox(
 417            self._button_frame, textvariable=self._threshold_var,
 418            from_=0.0, to=1.0, increment=0.05, width=18)
 419        self._threshold_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 420        self._threshold_var.trace_add(
 421            "write", self._on_field_changed_trace)
 422        _WidgetTooltip(self._threshold_label, TIP_THRESHOLD)
 423        _WidgetTooltip(self._threshold_spin, TIP_THRESHOLD)
 424
 425        row += 1
 426        self._btn_info_label = ttk.Label(
 427            self._button_frame,
 428            text="Select a button-type action\nto edit trigger options.",
 429            foreground="#888888", justify=tk.CENTER)
 430        self._btn_info_label.grid(
 431            row=row, column=0, columnspan=2, sticky=tk.NSEW, pady=10)
 432
 433        self._button_frame.columnconfigure(1, weight=1)
 434
 435    def _build_analog_options(self):
 436        self._analog_frame = ttk.LabelFrame(
 437            self._options_container, text="Analog Options", padding=6,
 438            style="Inactive.TLabelframe")
 439
 440        row = 0
 441        self._analog_trigger_label = ttk.Label(
 442            self._analog_frame, text="Trigger Mode:")
 443        self._analog_trigger_label.grid(
 444            row=row, column=0, sticky=tk.W, pady=2)
 445        self._analog_trigger_var = tk.StringVar()
 446        self._analog_trigger_combo = ttk.Combobox(
 447            self._analog_frame, textvariable=self._analog_trigger_var,
 448            values=[m.value for m in ANALOG_EVENT_TRIGGER_MODES],
 449            state="readonly", width=18)
 450        self._analog_trigger_combo.grid(
 451            row=row, column=1, sticky=tk.EW, pady=2)
 452        self._analog_trigger_var.trace_add(
 453            "write", self._on_field_changed_trace)
 454        self._analog_trigger_var.trace_add(
 455            "write", self._check_spline_gate)
 456
 457        row += 1
 458        self._deadband_label = ttk.Label(
 459            self._analog_frame, text="Deadband:")
 460        self._deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 461        self._deadband_var = tk.StringVar(value="0.0")
 462        self._deadband_spin = ttk.Spinbox(
 463            self._analog_frame, textvariable=self._deadband_var,
 464            from_=0.0, to=1.0, increment=0.01, width=17)
 465        self._deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 466        self._deadband_var.trace_add("write", self._on_field_changed_trace)
 467
 468        row += 1
 469        self._inversion_label = ttk.Label(
 470            self._analog_frame, text="Inversion:")
 471        self._inversion_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 472        self._inversion_var = tk.BooleanVar(value=False)
 473        self._inversion_check = ttk.Checkbutton(
 474            self._analog_frame, variable=self._inversion_var)
 475        self._inversion_check.grid(row=row, column=1, sticky=tk.W, pady=2)
 476        self._inversion_var.trace_add("write", self._on_field_changed_trace)
 477
 478        row += 1
 479        self._scale_label = ttk.Label(
 480            self._analog_frame, text="Scale:")
 481        self._scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 482        self._scale_var = tk.StringVar(value="1.0")
 483        self._scale_spin = ttk.Spinbox(
 484            self._analog_frame, textvariable=self._scale_var,
 485            from_=-10.0, to=10.0, increment=0.1, width=17)
 486        self._scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 487        self._scale_var.trace_add("write", self._on_field_changed_trace)
 488
 489        row += 1
 490        self._slew_label = ttk.Label(
 491            self._analog_frame, text="Slew Rate:")
 492        self._slew_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 493        self._slew_var = tk.StringVar(value="0.0")
 494        self._slew_spin = ttk.Spinbox(
 495            self._analog_frame, textvariable=self._slew_var,
 496            from_=0.0, to=100.0, increment=0.1, width=17)
 497        self._slew_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 498        self._slew_var.trace_add("write", self._on_field_changed_trace)
 499
 500        row += 1
 501        self._neg_slew_frame = ttk.Frame(self._analog_frame)
 502        self._neg_slew_frame.grid(
 503            row=row, column=0, columnspan=2, sticky=tk.EW, pady=2)
 504        self._neg_slew_enable_var = tk.BooleanVar(value=False)
 505        self._neg_slew_check = ttk.Checkbutton(
 506            self._neg_slew_frame, text="Neg. Slew Rate:",
 507            variable=self._neg_slew_enable_var)
 508        self._neg_slew_check.pack(side=tk.LEFT)
 509        self._neg_slew_var = tk.StringVar(value="0.0")
 510        self._neg_slew_spin = ttk.Spinbox(
 511            self._neg_slew_frame, textvariable=self._neg_slew_var,
 512            from_=-100.0, to=0.0, increment=0.1, width=10)
 513        self._neg_slew_spin.pack(side=tk.LEFT, fill=tk.X, expand=True)
 514        self._neg_slew_enable_var.trace_add(
 515            "write", self._on_neg_slew_toggled)
 516        self._neg_slew_var.trace_add("write", self._on_field_changed_trace)
 517
 518        self._axis_widgets = [
 519            self._deadband_spin,
 520            self._inversion_check,
 521            self._scale_spin,
 522            self._slew_spin,
 523            self._neg_slew_check,
 524            self._neg_slew_spin,
 525        ]
 526
 527        self._analog_frame.columnconfigure(1, weight=1)
 528
 529    def _build_va_options(self):
 530        self._va_frame = ttk.LabelFrame(
 531            self._options_container, text="Virtual Analog Options", padding=6,
 532            style="Inactive.TLabelframe")
 533
 534        # Two-column layout: left = generator params, right = shaping
 535        self._va_left = ttk.Frame(self._va_frame)
 536        self._va_left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 4))
 537        self._va_right = ttk.Frame(self._va_frame)
 538        self._va_right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0))
 539
 540        # --- Left column: VA generator fields ---
 541        row = 0
 542        lbl = ttk.Label(self._va_left, text="Button Mode:")
 543        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 544        self._va_mode_var = tk.StringVar(value="held")
 545        self._va_mode_combo = ttk.Combobox(
 546            self._va_left, textvariable=self._va_mode_var,
 547            values=["held", "toggle"], state="readonly", width=10)
 548        self._va_mode_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 549        self._va_mode_var.trace_add("write", self._on_field_changed_trace)
 550        _WidgetTooltip(lbl, TIP_VA_BUTTON_MODE)
 551        _WidgetTooltip(self._va_mode_combo, TIP_VA_BUTTON_MODE)
 552
 553        row += 1
 554        lbl = ttk.Label(self._va_left, text="Ramp Rate:")
 555        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 556        self._va_ramp_var = tk.StringVar(value="1.0")
 557        self._va_ramp_spin = ttk.Spinbox(
 558            self._va_left, textvariable=self._va_ramp_var,
 559            from_=0.0, to=100.0, increment=0.1, width=10)
 560        self._va_ramp_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 561        self._va_ramp_var.trace_add("write", self._on_field_changed_trace)
 562        _WidgetTooltip(lbl, TIP_VA_RAMP_RATE)
 563        _WidgetTooltip(self._va_ramp_spin, TIP_VA_RAMP_RATE)
 564
 565        row += 1
 566        lbl = ttk.Label(self._va_left, text="Acceleration:")
 567        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 568        self._va_accel_var = tk.StringVar(value="0.0")
 569        self._va_accel_spin = ttk.Spinbox(
 570            self._va_left, textvariable=self._va_accel_var,
 571            from_=0.0, to=100.0, increment=0.1, width=10)
 572        self._va_accel_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 573        self._va_accel_var.trace_add("write", self._on_field_changed_trace)
 574        _WidgetTooltip(lbl, TIP_VA_ACCELERATION)
 575        _WidgetTooltip(self._va_accel_spin, TIP_VA_ACCELERATION)
 576
 577        row += 1
 578        lbl = ttk.Label(self._va_left, text="Target:")
 579        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 580        self._va_target_var = tk.StringVar(value="1.0")
 581        self._va_target_spin = ttk.Spinbox(
 582            self._va_left, textvariable=self._va_target_var,
 583            from_=-10.0, to=10.0, increment=0.1, width=10)
 584        self._va_target_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 585        self._va_target_var.trace_add("write", self._on_field_changed_trace)
 586        _WidgetTooltip(lbl, TIP_VA_TARGET)
 587        _WidgetTooltip(self._va_target_spin, TIP_VA_TARGET)
 588
 589        row += 1
 590        lbl = ttk.Label(self._va_left, text="Rest:")
 591        lbl.grid(row=row, column=0, sticky=tk.W, pady=2)
 592        self._va_rest_var = tk.StringVar(value="0.0")
 593        self._va_rest_spin = ttk.Spinbox(
 594            self._va_left, textvariable=self._va_rest_var,
 595            from_=-10.0, to=10.0, increment=0.1, width=10)
 596        self._va_rest_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 597        self._va_rest_var.trace_add("write", self._on_field_changed_trace)
 598        _WidgetTooltip(lbl, TIP_VA_REST)
 599        _WidgetTooltip(self._va_rest_spin, TIP_VA_REST)
 600
 601        row += 1
 602        self._va_zero_vel_var = tk.BooleanVar(value=False)
 603        self._va_zero_vel_check = ttk.Checkbutton(
 604            self._va_left, text="Zero vel on release",
 605            variable=self._va_zero_vel_var)
 606        self._va_zero_vel_check.grid(
 607            row=row, column=0, columnspan=2, sticky=tk.W, pady=2)
 608        self._va_zero_vel_var.trace_add(
 609            "write", self._on_field_changed_trace)
 610        _WidgetTooltip(self._va_zero_vel_check, TIP_VA_ZERO_VEL)
 611
 612        row += 1
 613        self._va_neg_ramp_enable_var = tk.BooleanVar(value=False)
 614        self._va_neg_ramp_check = ttk.Checkbutton(
 615            self._va_left, text="Neg Ramp:",
 616            variable=self._va_neg_ramp_enable_var)
 617        self._va_neg_ramp_check.grid(row=row, column=0, sticky=tk.W)
 618        self._va_neg_ramp_var = tk.StringVar(value="0.0")
 619        self._va_neg_ramp_spin = ttk.Spinbox(
 620            self._va_left, textvariable=self._va_neg_ramp_var,
 621            from_=0.0, to=100.0, increment=0.1, width=10)
 622        self._va_neg_ramp_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 623        self._va_neg_ramp_spin.config(state="disabled")
 624        self._va_neg_ramp_enable_var.trace_add(
 625            "write", self._on_va_neg_ramp_toggled)
 626        self._va_neg_ramp_var.trace_add(
 627            "write", self._on_field_changed_trace)
 628        _WidgetTooltip(self._va_neg_ramp_check, TIP_VA_NEG_RAMP)
 629        _WidgetTooltip(self._va_neg_ramp_spin, TIP_VA_NEG_RAMP)
 630
 631        row += 1
 632        self._va_neg_accel_enable_var = tk.BooleanVar(value=False)
 633        self._va_neg_accel_check = ttk.Checkbutton(
 634            self._va_left, text="Neg Accel:",
 635            variable=self._va_neg_accel_enable_var)
 636        self._va_neg_accel_check.grid(row=row, column=0, sticky=tk.W)
 637        self._va_neg_accel_var = tk.StringVar(value="0.0")
 638        self._va_neg_accel_spin = ttk.Spinbox(
 639            self._va_left, textvariable=self._va_neg_accel_var,
 640            from_=0.0, to=100.0, increment=0.1, width=10)
 641        self._va_neg_accel_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 642        self._va_neg_accel_spin.config(state="disabled")
 643        self._va_neg_accel_enable_var.trace_add(
 644            "write", self._on_va_neg_accel_toggled)
 645        self._va_neg_accel_var.trace_add(
 646            "write", self._on_field_changed_trace)
 647        _WidgetTooltip(self._va_neg_accel_check, TIP_VA_NEG_ACCEL)
 648        _WidgetTooltip(self._va_neg_accel_spin, TIP_VA_NEG_ACCEL)
 649
 650        self._va_left.columnconfigure(1, weight=1)
 651
 652        # --- Right column: trigger mode + analog shaping ---
 653        row = 0
 654        self._va_trigger_label = ttk.Label(
 655            self._va_right, text="Trigger Mode:")
 656        self._va_trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 657        self._va_trigger_var = tk.StringVar()
 658        self._va_trigger_combo = ttk.Combobox(
 659            self._va_right, textvariable=self._va_trigger_var,
 660            values=[m.value for m in ANALOG_EVENT_TRIGGER_MODES],
 661            state="readonly", width=12)
 662        self._va_trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 663        self._va_trigger_var.trace_add(
 664            "write", self._on_field_changed_trace)
 665
 666        row += 1
 667        self._va_deadband_label = ttk.Label(
 668            self._va_right, text="Deadband:")
 669        self._va_deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 670        self._va_deadband_var = tk.StringVar(value="0.0")
 671        self._va_deadband_spin = ttk.Spinbox(
 672            self._va_right, textvariable=self._va_deadband_var,
 673            from_=0.0, to=1.0, increment=0.01, width=10)
 674        self._va_deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 675        self._va_deadband_var.trace_add(
 676            "write", self._on_field_changed_trace)
 677        _WidgetTooltip(self._va_deadband_label, TIP_DEADBAND)
 678        _WidgetTooltip(self._va_deadband_spin, TIP_DEADBAND)
 679
 680        row += 1
 681        self._va_scale_label = ttk.Label(self._va_right, text="Scale:")
 682        self._va_scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 683        self._va_scale_var = tk.StringVar(value="1.0")
 684        self._va_scale_spin = ttk.Spinbox(
 685            self._va_right, textvariable=self._va_scale_var,
 686            from_=-10.0, to=10.0, increment=0.1, width=10)
 687        self._va_scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 688        self._va_scale_var.trace_add(
 689            "write", self._on_field_changed_trace)
 690        _WidgetTooltip(self._va_scale_label, TIP_SCALE)
 691        _WidgetTooltip(self._va_scale_spin, TIP_SCALE)
 692
 693        self._va_right.columnconfigure(1, weight=1)
 694
 695    def _on_va_neg_ramp_toggled(self, *args):
 696        """Enable/disable the VA negative ramp rate spinbox."""
 697        enabled = self._va_neg_ramp_enable_var.get()
 698        self._va_neg_ramp_spin.config(
 699            state="normal" if enabled else "disabled")
 700        if not self._updating_form:
 701            self._on_field_changed_trace()
 702
 703    def _on_va_neg_accel_toggled(self, *args):
 704        """Enable/disable the VA negative acceleration spinbox."""
 705        enabled = self._va_neg_accel_enable_var.get()
 706        self._va_neg_accel_spin.config(
 707            state="normal" if enabled else "disabled")
 708        if not self._updating_form:
 709            self._on_field_changed_trace()
 710
 711    # --- Lower Section Placeholders ---
 712
 713    def _build_lower_section(self, parent):
 714        from .curve_editor_widget import CurveEditorWidget
 715
 716        self._lower_paned = tk.PanedWindow(
 717            parent, orient=tk.HORIZONTAL, sashwidth=5, sashrelief=tk.RAISED)
 718        lower_paned = self._lower_paned
 719        lower_paned.pack(fill=tk.BOTH, expand=True)
 720
 721        # Left: Curve Editor
 722        curve_frame = ttk.LabelFrame(
 723            lower_paned, text="Curve Editor", padding=4)
 724        lower_paned.add(curve_frame, minsize=120)
 725
 726        self._curve_editor = CurveEditorWidget(
 727            curve_frame,
 728            on_before_change=self._on_before_change,
 729            on_curve_changed=self._on_curve_changed,
 730            get_other_curves=self._get_other_curves,
 731            get_advanced_flags=self._get_advanced_flags,
 732        )
 733        self._curve_editor.pack(fill=tk.BOTH, expand=True)
 734
 735        # Right: Preview widget
 736        preview_frame = ttk.LabelFrame(
 737            lower_paned, text="Preview", padding=4)
 738        lower_paned.add(preview_frame, minsize=120)
 739
 740        self._preview = PreviewWidget(preview_frame)
 741        self._preview.pack(fill=tk.BOTH, expand=True)
 742
 743    # ------------------------------------------------------------------
 744    # Public API
 745    # ------------------------------------------------------------------
 746
 747    def on_advanced_changed(self):
 748        """Refresh UI elements affected by Advanced menu toggles."""
 749        if self._action and self._action.input_type == InputType.ANALOG:
 750            self._update_analog_trigger_values()
 751        # Refresh curve editor toolbar (monotonic gate)
 752        self._curve_editor.on_advanced_changed()
 753
 754    def load_action(self, action: ActionDefinition, qname: str):
 755        """Populate all panes from the selected action."""
 756        self._action = action
 757        self._qname = qname
 758        self._set_all_enabled(True)
 759
 760        self._updating_form = True
 761        try:
 762            self._name_var.set(action.name)
 763            # Populate group dropdown with all known groups
 764            if self._get_group_names:
 765                self._group_combo['values'] = self._get_group_names()
 766            elif self._get_all_actions:
 767                groups = sorted({a.group
 768                                 for a in self._get_all_actions().values()})
 769                self._group_combo['values'] = groups
 770            self._group_var.set(action.group)
 771            self._desc_text.delete("1.0", tk.END)
 772            self._desc_text.insert("1.0", action.description)
 773            self._desc_text.edit_modified(False)
 774            self._input_type_var.set(action.input_type.value)
 775
 776            # Trigger mode into the correct pane
 777            if action.input_type in (InputType.BUTTON,
 778                                     InputType.BOOLEAN_TRIGGER):
 779                self._btn_trigger_var.set(action.trigger_mode.value)
 780            elif action.input_type == InputType.VIRTUAL_ANALOG:
 781                self._va_trigger_var.set(action.trigger_mode.value)
 782            else:
 783                self._analog_trigger_var.set(action.trigger_mode.value)
 784
 785            # Threshold (BOOLEAN_TRIGGER)
 786            self._threshold_var.set(str(action.threshold))
 787
 788            # Analog fields
 789            self._deadband_var.set(str(action.deadband))
 790            self._inversion_var.set(action.inversion)
 791            self._scale_var.set(str(action.scale))
 792            self._slew_var.set(str(action.slew_rate))
 793            neg_slew = action.extra.get(EXTRA_NEGATIVE_SLEW_RATE)
 794            if neg_slew is not None:
 795                self._neg_slew_enable_var.set(True)
 796                self._neg_slew_var.set(str(min(float(neg_slew), 0.0)))
 797            else:
 798                self._neg_slew_enable_var.set(False)
 799                self._neg_slew_var.set("0.0")
 800
 801            # Virtual Analog fields
 802            self._va_mode_var.set(
 803                action.extra.get(EXTRA_VA_BUTTON_MODE, "held"))
 804            self._va_ramp_var.set(
 805                str(action.extra.get(EXTRA_VA_RAMP_RATE, 0.0)))
 806            self._va_accel_var.set(
 807                str(action.extra.get(EXTRA_VA_ACCELERATION, 0.0)))
 808            self._va_target_var.set(
 809                str(action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0)))
 810            self._va_rest_var.set(
 811                str(action.extra.get(EXTRA_VA_REST_VALUE, 0.0)))
 812            self._va_zero_vel_var.set(
 813                bool(action.extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False)))
 814            neg_ramp = action.extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
 815            if neg_ramp is not None:
 816                self._va_neg_ramp_enable_var.set(True)
 817                self._va_neg_ramp_var.set(str(float(neg_ramp)))
 818            else:
 819                self._va_neg_ramp_enable_var.set(False)
 820                self._va_neg_ramp_var.set("0.0")
 821            neg_accel = action.extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
 822            if neg_accel is not None:
 823                self._va_neg_accel_enable_var.set(True)
 824                self._va_neg_accel_var.set(str(float(neg_accel)))
 825            else:
 826                self._va_neg_accel_enable_var.set(False)
 827                self._va_neg_accel_var.set("0.0")
 828            # VA pane deadband/scale mirror main action fields
 829            self._va_deadband_var.set(str(action.deadband))
 830            self._va_scale_var.set(str(action.scale))
 831
 832            self._update_pane_states()
 833        finally:
 834            self._updating_form = False
 835
 836        self._refresh_bindings()
 837        self._curve_editor.load_action(
 838            action, qname, self._get_bound_input_names())
 839        self._preview.load_action(
 840            action, qname, self._get_bound_input_names(),
 841            binding_details=self._get_binding_details(),
 842            paired_action_info=self._find_paired_analog_action())
 843
 844    def clear(self):
 845        """Clear all panes (no action selected)."""
 846        self._action = None
 847        self._qname = None
 848
 849        self._updating_form = True
 850        try:
 851            self._name_var.set("")
 852            self._group_var.set("")
 853            self._desc_text.delete("1.0", tk.END)
 854            self._desc_text.edit_modified(False)
 855            self._input_type_var.set("")
 856            self._btn_trigger_var.set("")
 857            self._analog_trigger_var.set("")
 858            self._deadband_var.set("0.0")
 859            self._inversion_var.set(False)
 860            self._scale_var.set("1.0")
 861            self._slew_var.set("0.0")
 862            self._neg_slew_enable_var.set(False)
 863            self._neg_slew_var.set("0.0")
 864            # VA fields
 865            self._va_trigger_var.set("")
 866            self._va_mode_var.set("held")
 867            self._va_ramp_var.set("0.0")
 868            self._va_accel_var.set("0.0")
 869            self._va_target_var.set("1.0")
 870            self._va_rest_var.set("0.0")
 871            self._va_zero_vel_var.set(False)
 872            self._va_neg_ramp_enable_var.set(False)
 873            self._va_neg_ramp_var.set("0.0")
 874            self._va_neg_accel_enable_var.set(False)
 875            self._va_neg_accel_var.set("0.0")
 876            self._va_deadband_var.set("0.0")
 877            self._va_scale_var.set("1.0")
 878        finally:
 879            self._updating_form = False
 880
 881        self._bound_tree.delete(*self._bound_tree.get_children())
 882        self._bound_map.clear()
 883        self._assign_combo.config(values=[])
 884        self._assign_map.clear()
 885        self._set_all_enabled(False)
 886        self._curve_editor.clear()
 887        self._preview.clear()
 888
 889    def refresh_bindings(self):
 890        """Re-query binding info for the current action."""
 891        self._refresh_bindings()
 892
 893    # ------------------------------------------------------------------
 894    # Curve Editor Callbacks
 895    # ------------------------------------------------------------------
 896
 897    def _on_curve_changed(self):
 898        """Called by CurveEditorWidget when curve data is modified."""
 899        # The curve editor may have changed action.scale (via scale handle
 900        # drag). Sync the spinbox so _save_to_action won't overwrite it.
 901        if self._action:
 902            self._updating_form = True
 903            try:
 904                self._scale_var.set(str(self._action.scale))
 905                self._threshold_var.set(str(self._action.threshold))
 906            finally:
 907                self._updating_form = False
 908        self._preview.refresh()
 909        if self._on_field_changed:
 910            self._on_field_changed()
 911
 912    def _get_other_curves(self, mode: str) -> dict[str, list[dict]]:
 913        """Return curves from other actions for 'Copy from...'."""
 914        if not self._get_all_actions:
 915            return {}
 916        key = EXTRA_SPLINE_POINTS if mode == "spline" else EXTRA_SEGMENT_POINTS
 917        all_actions = self._get_all_actions()
 918        curves = {}
 919        for qname, action in all_actions.items():
 920            if qname != self._qname:
 921                pts = action.extra.get(key)
 922                if pts:
 923                    curves[qname] = pts
 924        return curves
 925
 926    def _update_curve_editor(self):
 927        """Refresh the curve editor when action parameters change."""
 928        if self._action:
 929            self._curve_editor.load_action(
 930                self._action, self._qname,
 931                self._get_bound_input_names())
 932
 933    # ------------------------------------------------------------------
 934    # Pane State Management
 935    # ------------------------------------------------------------------
 936
 937    def _set_all_enabled(self, enabled: bool):
 938        """Enable/disable all panes."""
 939        state = "normal" if enabled else "disabled"
 940        _set_children_state(self._common_frame, state)
 941        _set_children_state(self._bindings_frame, state)
 942        _set_children_state(self._button_frame, state)
 943        _set_children_state(self._analog_frame, state)
 944        _set_children_state(self._va_frame, state)
 945        if not enabled:
 946            self._show_options_pane(None)
 947
 948    def _show_options_pane(self, which: str | None):
 949        """Swap the right pane between 'button', 'analog', 'virtual_analog', or None."""
 950        if self._active_options == which:
 951            return
 952        # Hide current
 953        if self._active_options == "button":
 954            self._button_frame.pack_forget()
 955        elif self._active_options == "analog":
 956            self._analog_frame.pack_forget()
 957        elif self._active_options == "virtual_analog":
 958            self._va_frame.pack_forget()
 959
 960        # Show new
 961        self._active_options = which
 962        if which == "button":
 963            self._button_frame.pack(fill=tk.BOTH, expand=True)
 964            self._button_frame.configure(style="Active.TLabelframe")
 965            _set_children_state(self._button_frame, "normal")
 966        elif which == "analog":
 967            self._analog_frame.pack(fill=tk.BOTH, expand=True)
 968            self._analog_frame.configure(style="Active.TLabelframe")
 969            _set_children_state(self._analog_frame, "normal")
 970        elif which == "virtual_analog":
 971            self._va_frame.pack(fill=tk.BOTH, expand=True)
 972            self._va_frame.configure(style="Active.TLabelframe")
 973            _set_children_state(self._va_frame, "normal")
 974
 975    def _update_pane_states(self):
 976        """Activate the correct options pane based on input type."""
 977        if not self._action:
 978            self._show_options_pane(None)
 979            return
 980
 981        itype = self._action.input_type
 982        is_button = itype in (InputType.BUTTON, InputType.BOOLEAN_TRIGGER)
 983        is_analog = itype == InputType.ANALOG
 984        is_va = itype == InputType.VIRTUAL_ANALOG
 985
 986        if is_button:
 987            self._show_options_pane("button")
 988            self._btn_info_label.config(text="")
 989            # Threshold: enabled for BOOLEAN_TRIGGER, disabled for BUTTON
 990            thresh_state = ("normal" if itype == InputType.BOOLEAN_TRIGGER
 991                            else "disabled")
 992            self._threshold_spin.config(state=thresh_state)
 993        elif is_analog:
 994            self._show_options_pane("analog")
 995            self._update_analog_trigger_values()
 996            self._update_raw_mode_disable()
 997            self._on_neg_slew_toggled()
 998        elif is_va:
 999            self._show_options_pane("virtual_analog")
1000            self._on_va_neg_ramp_toggled()
1001            self._on_va_neg_accel_toggled()
1002        else:
1003            # OUTPUT or other — show button pane as placeholder
1004            self._show_options_pane("button")
1005            _set_children_state(self._button_frame, "disabled")
1006            self._button_frame.configure(style="Inactive.TLabelframe")
1007            self._btn_info_label.config(
1008                text="No type-specific options\nfor this input type.")
1009
1010    _SPLINE_ADV_SUFFIX = "  (Advanced)"
1011
1012    def _update_analog_trigger_values(self):
1013        """Refresh analog trigger combo values based on advanced flags."""
1014        flags = self._get_advanced_flags()
1015        current = self._analog_trigger_var.get()
1016        values = []
1017        for m in ANALOG_EVENT_TRIGGER_MODES:
1018            label = m.value
1019            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1020                    and current != EventTriggerMode.SPLINE.value):
1021                label += self._SPLINE_ADV_SUFFIX
1022            values.append(label)
1023        self._analog_trigger_combo['values'] = values
1024
1025    def _check_spline_gate(self, *args):
1026        """Revert spline selection if splines are disabled."""
1027        if self._updating_form:
1028            return
1029        val = self._analog_trigger_var.get()
1030        if val.endswith(self._SPLINE_ADV_SUFFIX):
1031            self._updating_form = True
1032            self._analog_trigger_var.set(
1033                self._action.trigger_mode.value
1034                if self._action else EventTriggerMode.SCALED.value)
1035            self._updating_form = False
1036            messagebox.showinfo(
1037                "Advanced Feature",
1038                "Enable splines in Advanced menu to use this mode.")
1039
1040    def _update_raw_mode_disable(self):
1041        """Disable axis fields when trigger mode is RAW."""
1042        if not self._action:
1043            return
1044        if self._action.input_type not in (
1045                InputType.ANALOG, InputType.VIRTUAL_ANALOG):
1046            return
1047        trig_var = (self._va_trigger_var
1048                    if self._action.input_type == InputType.VIRTUAL_ANALOG
1049                    else self._analog_trigger_var)
1050        is_raw = trig_var.get() == EventTriggerMode.RAW.value
1051        state = "disabled" if is_raw else "normal"
1052        for w in self._axis_widgets:
1053            try:
1054                w.config(state=state)
1055            except tk.TclError:
1056                pass  # Some widgets don't support the 'state' option
1057        if not is_raw:
1058            neg_state = ("normal" if self._neg_slew_enable_var.get()
1059                         else "disabled")
1060            self._neg_slew_spin.config(state=neg_state)
1061
1062    # ------------------------------------------------------------------
1063    # Binding Management
1064    # ------------------------------------------------------------------
1065
1066    def _refresh_bindings(self):
1067        """Refresh the assigned-inputs treeview and assign dropdown."""
1068        self._bound_tree.delete(*self._bound_tree.get_children())
1069        self._bound_map.clear()
1070        self._binding_icons.clear()
1071        self._assign_combo.config(values=[])
1072        self._assign_map.clear()
1073
1074        if not self._qname:
1075            return
1076        if not (self._get_all_controllers and self._get_compatible_inputs
1077                and self._is_action_bound):
1078            return
1079
1080        controllers = self._get_all_controllers()
1081        all_inputs = self._get_compatible_inputs(self._qname)
1082
1083        # Populate assigned inputs treeview + bound_map
1084        for port, ctrl_name in controllers:
1085            for input_name, display in all_inputs:
1086                if self._is_action_bound(self._qname, port, input_name):
1087                    label = f"{ctrl_name}: {display}"
1088                    icon = None
1089                    if self._icon_loader:
1090                        icon = self._icon_loader.get_tk_icon(
1091                            input_name, 20)
1092                    kwargs = {}
1093                    if icon:
1094                        self._binding_icons.append(icon)
1095                        kwargs["image"] = icon
1096                    self._bound_tree.insert(
1097                        "", tk.END, text=label, **kwargs)
1098                    self._bound_map[label] = (port, input_name)
1099
1100        # Populate assign dropdown with compatible unbound inputs
1101        options = []
1102        for port, ctrl_name in controllers:
1103            for input_name, display in all_inputs:
1104                if not self._is_action_bound(
1105                        self._qname, port, input_name):
1106                    label = f"{ctrl_name}: {display}"
1107                    options.append(label)
1108                    self._assign_map[label] = (port, input_name)
1109        self._assign_combo.config(values=options)
1110        if options:
1111            self._assign_var.set(options[0])
1112        else:
1113            self._assign_var.set("")
1114
1115    def _get_bound_input_names(self) -> list[str]:
1116        """Return list of input names currently bound to this action."""
1117        return [inp for _, inp in self._bound_map.values()]
1118
1119    def _get_binding_details(self) -> list[tuple[int, str]]:
1120        """Return list of (port, input_name) for current action bindings."""
1121        return list(self._bound_map.values())
1122
1123    def _find_paired_analog_action(self) -> tuple | None:
1124        """Find the paired stick-axis action for 2D preview overlay.
1125
1126        Returns (ActionDefinition, qname) if a paired analog action
1127        exists, else None.
1128        """
1129        if not self._bound_map or not self._get_all_actions:
1130            return None
1131        # Find first stick binding
1132        primary_port = None
1133        paired_input = None
1134        for _label, (port, input_name) in self._bound_map.items():
1135            paired = STICK_PAIRS.get(input_name)
1136            if paired:
1137                primary_port = port
1138                paired_input = paired
1139                break
1140        if not paired_input:
1141            return None
1142        # Search all actions for one bound to paired_input on same port
1143        all_actions = self._get_all_actions()
1144        for qname, action in all_actions.items():
1145            if qname == self._qname:
1146                continue
1147            if (action.input_type == InputType.ANALOG
1148                    and self._is_action_bound
1149                    and self._is_action_bound(
1150                        qname, primary_port, paired_input)):
1151                return (action, qname)
1152        return None
1153
1154    def _on_assign(self):
1155        """Assign the selected input to the current action."""
1156        if not self._qname:
1157            return
1158        label = self._assign_var.get()
1159        mapping = self._assign_map.get(label)
1160        if not mapping:
1161            return
1162        port, input_name = mapping
1163        if self._on_before_change:
1164            self._on_before_change(0)
1165        if self._on_assign_action:
1166            self._on_assign_action(self._qname, port, input_name)
1167        self._refresh_bindings()
1168        bound = self._get_bound_input_names()
1169        self._curve_editor.update_bindings(bound)
1170        self._preview.update_bindings(
1171            bound,
1172            binding_details=self._get_binding_details(),
1173            paired_action_info=self._find_paired_analog_action())
1174        if self._on_field_changed:
1175            self._on_field_changed()
1176
1177    def _on_unassign(self):
1178        """Remove the selected binding from the current action."""
1179        if not self._qname:
1180            return
1181        sel = self._bound_tree.selection()
1182        if not sel:
1183            return
1184        label = self._bound_tree.item(sel[0], "text")
1185        mapping = self._bound_map.get(label)
1186        if not mapping:
1187            return
1188        port, input_name = mapping
1189        if self._on_before_change:
1190            self._on_before_change(0)
1191        if self._on_unassign_action:
1192            self._on_unassign_action(self._qname, port, input_name)
1193        self._refresh_bindings()
1194        bound = self._get_bound_input_names()
1195        self._curve_editor.update_bindings(bound)
1196        self._preview.update_bindings(
1197            bound,
1198            binding_details=self._get_binding_details(),
1199            paired_action_info=self._find_paired_analog_action())
1200        if self._on_field_changed:
1201            self._on_field_changed()
1202
1203    # ------------------------------------------------------------------
1204    # Field Change Handlers
1205    # ------------------------------------------------------------------
1206
1207    def _on_field_changed_trace(self, *args):
1208        """Trace callback for variable writes."""
1209        if self._updating_form or not self._action:
1210            return
1211        self._save_to_action()
1212        # Refresh spline gate after trigger mode change
1213        if self._action and self._action.input_type == InputType.ANALOG:
1214            self._update_analog_trigger_values()
1215        self._update_curve_editor()
1216        self._preview.refresh()
1217        if self._on_field_changed:
1218            self._on_field_changed()
1219
1220    def _set_field_error(self, widget, has_error: bool):
1221        """Apply or clear error styling on a ttk Entry or Combobox."""
1222        base = widget.winfo_class()
1223        widget.configure(style=f"Error.{base}" if has_error else f"{base}")
1224
1225    def _flash_field_warning(self, widget, err: str):
1226        """Flash error styling and show a warning for an invalid field."""
1227        if getattr(self, '_showing_warning', False):
1228            return
1229        self._showing_warning = True
1230        self._set_field_error(widget, True)
1231        messagebox.showwarning("Invalid Value", err)
1232        self._set_field_error(widget, False)
1233        self._showing_warning = False
1234
1235    def _commit_name_group(self, event=None):
1236        """Commit name/group changes on focus-out or Enter."""
1237        if self._updating_form or not self._action:
1238            return
1239
1240        action = self._action
1241        new_name = self._name_var.get().strip()
1242        new_group = self._group_var.get().strip()
1243        changed = False
1244
1245        # Validate and apply name
1246        if new_name and new_name != action.name:
1247            err = validate_action_name(new_name)
1248            if err:
1249                self._flash_field_warning(self._name_entry, err)
1250                self._updating_form = True
1251                self._name_var.set(action.name)
1252                self._updating_form = False
1253            else:
1254                action.name = new_name
1255                changed = True
1256        elif not new_name:
1257            self._flash_field_warning(
1258                self._name_entry, "Name cannot be empty.")
1259            self._updating_form = True
1260            self._name_var.set(action.name)
1261            self._updating_form = False
1262        self._set_field_error(self._name_entry, False)
1263
1264        # Validate and apply group
1265        if new_group and new_group != action.group:
1266            err = validate_action_group(new_group)
1267            if err:
1268                self._flash_field_warning(self._group_combo, err)
1269                self._updating_form = True
1270                self._group_var.set(action.group)
1271                self._updating_form = False
1272            else:
1273                action.group = new_group
1274                changed = True
1275        elif not new_group:
1276            self._flash_field_warning(
1277                self._group_combo, "Group cannot be empty.")
1278            self._updating_form = True
1279            self._group_var.set(action.group)
1280            self._updating_form = False
1281        self._set_field_error(self._group_combo, False)
1282
1283        if changed and self._on_field_changed:
1284            self._on_field_changed()
1285
1286    def _on_desc_modified(self, event=None):
1287        """Handle description Text widget changes."""
1288        if not self._desc_text.edit_modified():
1289            return
1290        self._desc_text.edit_modified(False)
1291        if self._updating_form or not self._action:
1292            return
1293        self._save_to_action()
1294        if self._on_field_changed:
1295            self._on_field_changed()
1296
1297    def _on_input_type_changed_trace(self, *args):
1298        """Handle input type changes with warning and pane switching."""
1299        if self._updating_form or not self._action:
1300            return
1301
1302        new_type_str = self._input_type_var.get()
1303        if not new_type_str:
1304            return
1305        try:
1306            new_type = InputType(new_type_str)
1307        except ValueError:
1308            return
1309
1310        if new_type == self._action.input_type:
1311            return
1312
1313        # Warn if action has custom settings
1314        if getattr(self._action, '_has_custom', False):
1315            if not messagebox.askyesno(
1316                "Change Input Type",
1317                "Changing input type may reset or\n"
1318                "invalidate current settings (deadband,\n"
1319                "scale, curves, bindings). Continue?",
1320            ):
1321                # Defer revert — setting a var inside its own trace is unreliable
1322                old_val = self._action.input_type.value
1323                self.after_idle(self._revert_input_type, old_val)
1324                return
1325
1326        if self._on_before_change:
1327            self._on_before_change(0)
1328
1329        self._type_switch_active = True
1330        try:
1331            self._action.input_type = new_type
1332
1333            if new_type == InputType.ANALOG:
1334                if self._action.trigger_mode in BUTTON_EVENT_TRIGGER_MODES:
1335                    self._action.trigger_mode = EventTriggerMode.SCALED
1336                if self._action.deadband < 0.01:
1337                    self._action.deadband = 0.05
1338            elif new_type in (InputType.BUTTON, InputType.BOOLEAN_TRIGGER):
1339                if self._action.trigger_mode in ANALOG_EVENT_TRIGGER_MODES:
1340                    self._action.trigger_mode = EventTriggerMode.ON_TRUE
1341
1342            self.load_action(self._action, self._qname)
1343        finally:
1344            self._type_switch_active = False
1345
1346        self._action._has_custom = False
1347        if self._on_field_changed:
1348            self._on_field_changed()
1349
1350    def _revert_input_type(self, old_val):
1351        """Revert the input type radio after user cancelled type switch."""
1352        self._updating_form = True
1353        try:
1354            self._input_type_var.set(old_val)
1355        finally:
1356            self._updating_form = False
1357
1358    def _on_neg_slew_toggled(self, *args):
1359        """Enable/disable the negative slew rate spinbox."""
1360        if self._action and self._action.input_type == InputType.ANALOG:
1361            is_raw = (self._analog_trigger_var.get()
1362                      == EventTriggerMode.RAW.value)
1363            if not is_raw:
1364                enabled = self._neg_slew_enable_var.get()
1365                self._neg_slew_spin.config(
1366                    state="normal" if enabled else "disabled")
1367        if not self._updating_form:
1368            self._on_field_changed_trace()
1369
1370    def _save_to_action(self):
1371        """Write current form values back to the ActionDefinition."""
1372        action = self._action
1373        if not action:
1374            return
1375
1376        if self._on_before_change:
1377            self._on_before_change(200)
1378
1379        action.description = self._desc_text.get("1.0", "end-1c").strip()
1380        # Name and group are committed on focus-out/Enter, not on every
1381        # keystroke — see _commit_name_group().
1382
1383        # Trigger mode from the active pane
1384        if action.input_type in (InputType.BUTTON,
1385                                 InputType.BOOLEAN_TRIGGER):
1386            trigger_str = self._btn_trigger_var.get()
1387        elif action.input_type == InputType.VIRTUAL_ANALOG:
1388            trigger_str = self._va_trigger_var.get()
1389        else:
1390            trigger_str = self._analog_trigger_var.get()
1391        # Strip the "(Advanced)" suffix if present (user should not be able
1392        # to select it, but guard against it).
1393        if trigger_str and trigger_str.endswith(self._SPLINE_ADV_SUFFIX):
1394            trigger_str = ""
1395        if trigger_str:
1396            try:
1397                action.trigger_mode = EventTriggerMode(trigger_str)
1398            except ValueError:
1399                pass  # Invalid trigger mode string; keep previous value
1400
1401        # Threshold (BOOLEAN_TRIGGER)
1402        if action.input_type == InputType.BOOLEAN_TRIGGER:
1403            try:
1404                action.threshold = float(
1405                    self._threshold_var.get() or 0.5)
1406            except ValueError:
1407                pass  # Invalid input; keep previous threshold
1408
1409        # Analog fields
1410        try:
1411            action.deadband = float(self._deadband_var.get() or 0.0)
1412        except ValueError:
1413            pass  # Invalid input; keep previous deadband value
1414        action.inversion = self._inversion_var.get()
1415        try:
1416            action.scale = float(self._scale_var.get() or 1.0)
1417        except ValueError:
1418            pass  # Invalid input; keep previous scale value
1419        try:
1420            action.slew_rate = float(self._slew_var.get() or 0.0)
1421        except ValueError:
1422            pass  # Invalid input; keep previous slew rate value
1423
1424        if self._neg_slew_enable_var.get():
1425            try:
1426                val = float(self._neg_slew_var.get() or 0.0)
1427                action.extra[EXTRA_NEGATIVE_SLEW_RATE] = min(val, 0.0)
1428            except ValueError:
1429                pass  # Invalid input; keep previous negative slew value
1430        else:
1431            action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1432
1433        # Virtual Analog extra fields
1434        if action.input_type == InputType.VIRTUAL_ANALOG:
1435            try:
1436                action.extra[EXTRA_VA_BUTTON_MODE] = (
1437                    self._va_mode_var.get() or "held")
1438                action.extra[EXTRA_VA_RAMP_RATE] = float(
1439                    self._va_ramp_var.get() or 0.0)
1440                action.extra[EXTRA_VA_ACCELERATION] = float(
1441                    self._va_accel_var.get() or 0.0)
1442                action.extra[EXTRA_VA_TARGET_VALUE] = float(
1443                    self._va_target_var.get() or 1.0)
1444                action.extra[EXTRA_VA_REST_VALUE] = float(
1445                    self._va_rest_var.get() or 0.0)
1446                action.extra[EXTRA_VA_ZERO_VEL_ON_RELEASE] = bool(
1447                    self._va_zero_vel_var.get())
1448                if self._va_neg_ramp_enable_var.get():
1449                    action.extra[EXTRA_VA_NEGATIVE_RAMP_RATE] = float(
1450                        self._va_neg_ramp_var.get() or 0.0)
1451                else:
1452                    action.extra.pop(EXTRA_VA_NEGATIVE_RAMP_RATE, None)
1453                if self._va_neg_accel_enable_var.get():
1454                    action.extra[EXTRA_VA_NEGATIVE_ACCELERATION] = float(
1455                        self._va_neg_accel_var.get() or 0.0)
1456                else:
1457                    action.extra.pop(EXTRA_VA_NEGATIVE_ACCELERATION, None)
1458                # VA pane has its own deadband/scale spinboxes
1459                action.deadband = float(
1460                    self._va_deadband_var.get() or 0.0)
1461                action.scale = float(
1462                    self._va_scale_var.get() or 1.0)
1463            except ValueError:
1464                pass  # Non-numeric entry; keep previous values
1465
1466        if not self._type_switch_active:
1467            action._has_custom = True
1468
1469        self._update_raw_mode_disable()

Detailed action editor shown as a notebook tab.

Upper section has three panes: Common (left), Assigned Inputs (center), and a swappable Button/Analog options pane (right). Lower section has placeholders for future curve editor and preview.

ActionEditorTab( parent, *, on_before_change=None, on_field_changed=None, get_binding_info=None, on_assign_action=None, on_unassign_action=None, get_all_controllers=None, get_compatible_inputs=None, is_action_bound=None, get_all_actions=None, get_group_names=None, get_advanced_flags=None, icon_loader=None)
 87    def __init__(self, parent, *,
 88                 on_before_change=None,
 89                 on_field_changed=None,
 90                 get_binding_info=None,
 91                 on_assign_action=None,
 92                 on_unassign_action=None,
 93                 get_all_controllers=None,
 94                 get_compatible_inputs=None,
 95                 is_action_bound=None,
 96                 get_all_actions=None,
 97                 get_group_names=None,
 98                 get_advanced_flags=None,
 99                 icon_loader=None):
100        super().__init__(parent)
101        _configure_styles()
102
103        self._on_before_change = on_before_change
104        self._on_field_changed = on_field_changed
105        self._get_all_actions = get_all_actions
106        self._get_group_names = get_group_names
107        self._get_advanced_flags = get_advanced_flags or (
108            lambda: {"splines": True, "nonmono": True})
109        self._get_binding_info = get_binding_info
110        self._on_assign_action = on_assign_action
111        self._on_unassign_action = on_unassign_action
112        self._get_all_controllers = get_all_controllers
113        self._get_compatible_inputs = get_compatible_inputs
114        self._is_action_bound = is_action_bound
115        self._icon_loader = icon_loader
116        self._binding_icons: list = []  # Prevent GC of PhotoImage refs
117
118        self._action: ActionDefinition | None = None
119        self._qname: str | None = None
120        self._updating_form = False
121        self._type_switch_active = False
122
123        self._assign_map: dict[str, tuple[int, str]] = {}
124        self._bound_map: dict[str, tuple[int, str]] = {}
125
126        self._build_ui()
127        self._set_all_enabled(False)

Construct a Ttk Frame with parent master.

STANDARD OPTIONS

class, cursor, style, takefocus

WIDGET-SPECIFIC OPTIONS

borderwidth, relief, padding, width, height
def set_sash_positions(self, positions: list[int]):
241    def set_sash_positions(self, positions: list[int]):
242        """Store saved sash positions to apply on first configure."""
243        self._saved_sash = positions

Store saved sash positions to apply on first configure.

def on_advanced_changed(self):
747    def on_advanced_changed(self):
748        """Refresh UI elements affected by Advanced menu toggles."""
749        if self._action and self._action.input_type == InputType.ANALOG:
750            self._update_analog_trigger_values()
751        # Refresh curve editor toolbar (monotonic gate)
752        self._curve_editor.on_advanced_changed()

Refresh UI elements affected by Advanced menu toggles.

def load_action(self, action: utils.controller.ActionDefinition, qname: str):
754    def load_action(self, action: ActionDefinition, qname: str):
755        """Populate all panes from the selected action."""
756        self._action = action
757        self._qname = qname
758        self._set_all_enabled(True)
759
760        self._updating_form = True
761        try:
762            self._name_var.set(action.name)
763            # Populate group dropdown with all known groups
764            if self._get_group_names:
765                self._group_combo['values'] = self._get_group_names()
766            elif self._get_all_actions:
767                groups = sorted({a.group
768                                 for a in self._get_all_actions().values()})
769                self._group_combo['values'] = groups
770            self._group_var.set(action.group)
771            self._desc_text.delete("1.0", tk.END)
772            self._desc_text.insert("1.0", action.description)
773            self._desc_text.edit_modified(False)
774            self._input_type_var.set(action.input_type.value)
775
776            # Trigger mode into the correct pane
777            if action.input_type in (InputType.BUTTON,
778                                     InputType.BOOLEAN_TRIGGER):
779                self._btn_trigger_var.set(action.trigger_mode.value)
780            elif action.input_type == InputType.VIRTUAL_ANALOG:
781                self._va_trigger_var.set(action.trigger_mode.value)
782            else:
783                self._analog_trigger_var.set(action.trigger_mode.value)
784
785            # Threshold (BOOLEAN_TRIGGER)
786            self._threshold_var.set(str(action.threshold))
787
788            # Analog fields
789            self._deadband_var.set(str(action.deadband))
790            self._inversion_var.set(action.inversion)
791            self._scale_var.set(str(action.scale))
792            self._slew_var.set(str(action.slew_rate))
793            neg_slew = action.extra.get(EXTRA_NEGATIVE_SLEW_RATE)
794            if neg_slew is not None:
795                self._neg_slew_enable_var.set(True)
796                self._neg_slew_var.set(str(min(float(neg_slew), 0.0)))
797            else:
798                self._neg_slew_enable_var.set(False)
799                self._neg_slew_var.set("0.0")
800
801            # Virtual Analog fields
802            self._va_mode_var.set(
803                action.extra.get(EXTRA_VA_BUTTON_MODE, "held"))
804            self._va_ramp_var.set(
805                str(action.extra.get(EXTRA_VA_RAMP_RATE, 0.0)))
806            self._va_accel_var.set(
807                str(action.extra.get(EXTRA_VA_ACCELERATION, 0.0)))
808            self._va_target_var.set(
809                str(action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0)))
810            self._va_rest_var.set(
811                str(action.extra.get(EXTRA_VA_REST_VALUE, 0.0)))
812            self._va_zero_vel_var.set(
813                bool(action.extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False)))
814            neg_ramp = action.extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
815            if neg_ramp is not None:
816                self._va_neg_ramp_enable_var.set(True)
817                self._va_neg_ramp_var.set(str(float(neg_ramp)))
818            else:
819                self._va_neg_ramp_enable_var.set(False)
820                self._va_neg_ramp_var.set("0.0")
821            neg_accel = action.extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
822            if neg_accel is not None:
823                self._va_neg_accel_enable_var.set(True)
824                self._va_neg_accel_var.set(str(float(neg_accel)))
825            else:
826                self._va_neg_accel_enable_var.set(False)
827                self._va_neg_accel_var.set("0.0")
828            # VA pane deadband/scale mirror main action fields
829            self._va_deadband_var.set(str(action.deadband))
830            self._va_scale_var.set(str(action.scale))
831
832            self._update_pane_states()
833        finally:
834            self._updating_form = False
835
836        self._refresh_bindings()
837        self._curve_editor.load_action(
838            action, qname, self._get_bound_input_names())
839        self._preview.load_action(
840            action, qname, self._get_bound_input_names(),
841            binding_details=self._get_binding_details(),
842            paired_action_info=self._find_paired_analog_action())

Populate all panes from the selected action.

def clear(self):
844    def clear(self):
845        """Clear all panes (no action selected)."""
846        self._action = None
847        self._qname = None
848
849        self._updating_form = True
850        try:
851            self._name_var.set("")
852            self._group_var.set("")
853            self._desc_text.delete("1.0", tk.END)
854            self._desc_text.edit_modified(False)
855            self._input_type_var.set("")
856            self._btn_trigger_var.set("")
857            self._analog_trigger_var.set("")
858            self._deadband_var.set("0.0")
859            self._inversion_var.set(False)
860            self._scale_var.set("1.0")
861            self._slew_var.set("0.0")
862            self._neg_slew_enable_var.set(False)
863            self._neg_slew_var.set("0.0")
864            # VA fields
865            self._va_trigger_var.set("")
866            self._va_mode_var.set("held")
867            self._va_ramp_var.set("0.0")
868            self._va_accel_var.set("0.0")
869            self._va_target_var.set("1.0")
870            self._va_rest_var.set("0.0")
871            self._va_zero_vel_var.set(False)
872            self._va_neg_ramp_enable_var.set(False)
873            self._va_neg_ramp_var.set("0.0")
874            self._va_neg_accel_enable_var.set(False)
875            self._va_neg_accel_var.set("0.0")
876            self._va_deadband_var.set("0.0")
877            self._va_scale_var.set("1.0")
878        finally:
879            self._updating_form = False
880
881        self._bound_tree.delete(*self._bound_tree.get_children())
882        self._bound_map.clear()
883        self._assign_combo.config(values=[])
884        self._assign_map.clear()
885        self._set_all_enabled(False)
886        self._curve_editor.clear()
887        self._preview.clear()

Clear all panes (no action selected).

def refresh_bindings(self):
889    def refresh_bindings(self):
890        """Re-query binding info for the current action."""
891        self._refresh_bindings()

Re-query binding info for the current action.