host.controller_config.action_panel

Action list and detail editor panel.

Left-side panel showing all defined actions organized in collapsible groups with add/edit/delete capabilities. When an action is selected, its metadata is shown in an editable detail form below the tree.

   1"""Action list and detail editor panel.
   2
   3Left-side panel showing all defined actions organized in collapsible
   4groups with add/edit/delete capabilities.  When an action is selected,
   5its metadata is shown in an editable detail form below the tree.
   6"""
   7
   8import tkinter as tk
   9from tkinter import ttk, messagebox, simpledialog
  10
  11import fnmatch
  12
  13from utils.controller.model import (
  14    ANALOG_EVENT_TRIGGER_MODES,
  15    ActionDefinition,
  16    BUTTON_EVENT_TRIGGER_MODES,
  17    DEFAULT_GROUP,
  18    EXTRA_NEGATIVE_SLEW_RATE,
  19    EXTRA_SEGMENT_POINTS,
  20    EXTRA_SPLINE_POINTS,
  21    EXTRA_VA_RAMP_RATE,
  22    EXTRA_VA_ACCELERATION,
  23    EXTRA_VA_NEGATIVE_RAMP_RATE,
  24    EXTRA_VA_NEGATIVE_ACCELERATION,
  25    EXTRA_VA_ZERO_VEL_ON_RELEASE,
  26    EXTRA_VA_TARGET_VALUE,
  27    EXTRA_VA_REST_VALUE,
  28    EXTRA_VA_BUTTON_MODE,
  29    InputType,
  30    EventTriggerMode,
  31    validate_action_name,
  32    validate_action_rename,
  33)
  34from .tooltips import (
  35    TIP_NAME, TIP_GROUP, TIP_DESC, TIP_INPUT_TYPE,
  36    TIP_TRIGGER, TIP_THRESHOLD, TIP_DEADBAND, TIP_INVERSION, TIP_SCALE,
  37    TIP_SLEW, TIP_NEG_SLEW,
  38    TIP_EDIT_SPLINE, TIP_EDIT_SEGMENTS,
  39    TIP_FILTER, TIP_FILTER_UNASSIGNED, TIP_FILTER_MULTI,
  40    TIP_VA_BUTTON_MODE,
  41    TIP_VA_RAMP_RATE, TIP_VA_ACCELERATION,
  42    TIP_VA_TARGET, TIP_VA_REST,
  43    TIP_VA_ZERO_VEL, TIP_VA_NEG_RAMP, TIP_VA_NEG_ACCEL,
  44)
  45
  46# Tooltip delay in milliseconds (500ms balances responsiveness vs flicker)
  47_TOOLTIP_DELAY_MS = 500
  48
  49# Suffix appended to SPLINE trigger mode when splines are disabled
  50_SPLINE_ADV_SUFFIX = "  (Advanced)"
  51
  52
  53class _WidgetTooltip:
  54    """Tooltip that appears after hovering over a widget for a delay."""
  55
  56    def __init__(self, widget, text: str, delay: int = _TOOLTIP_DELAY_MS):
  57        self._widget = widget
  58        self.text = text
  59        self._delay = delay
  60        self._tip_window: tk.Toplevel | None = None
  61        self._after_id: str | None = None
  62
  63        widget.bind("<Enter>", self._on_enter)
  64        widget.bind("<Leave>", self._on_leave)
  65
  66    def _on_enter(self, event):
  67        self._schedule()
  68
  69    def _on_leave(self, event):
  70        self._hide()
  71
  72    def _schedule(self):
  73        self._hide()
  74        self._after_id = self._widget.after(self._delay, self._show)
  75
  76    def _show(self):
  77        if not self.text:
  78            return
  79        self._tip_window = tw = tk.Toplevel(self._widget)
  80        tw.wm_overrideredirect(True)
  81
  82        x = self._widget.winfo_rootx() + 20
  83        y = self._widget.winfo_rooty() + self._widget.winfo_height() + 5
  84        tw.wm_geometry(f"+{x}+{y}")
  85
  86        label = tk.Label(tw, text=self.text, justify=tk.LEFT,
  87                         background="#ffffe0", foreground="#222222",
  88                         relief=tk.SOLID, borderwidth=1, padx=4, pady=2,
  89                         font=("TkDefaultFont", 9))
  90        label.pack()
  91
  92    def _hide(self):
  93        if self._after_id:
  94            self._widget.after_cancel(self._after_id)
  95            self._after_id = None
  96        if self._tip_window:
  97            self._tip_window.destroy()
  98            self._tip_window = None
  99
 100
 101class _TreeTooltip:
 102    """Tooltip that appears after hovering over a treeview item."""
 103
 104    def __init__(self, tree: ttk.Treeview, delay: int = _TOOLTIP_DELAY_MS):
 105        self._tree = tree
 106        self._delay = delay
 107        self._tip_window: tk.Toplevel | None = None
 108        self._after_id: str | None = None
 109        self._current_item: str | None = None
 110        self._text_fn = None  # callable(item_id) -> str | None
 111
 112        tree.bind("<Motion>", self._on_motion)
 113        tree.bind("<Leave>", self._on_leave)
 114
 115    def set_text_fn(self, fn):
 116        """Set a callable(item_id) -> str|None that provides tooltip text."""
 117        self._text_fn = fn
 118
 119    def _on_motion(self, event):
 120        item = self._tree.identify_row(event.y)
 121        if item != self._current_item:
 122            self._hide()
 123            self._current_item = item
 124            if item and self._text_fn:
 125                self._after_id = self._tree.after(
 126                    self._delay, lambda: self._show(item))
 127
 128    def _on_leave(self, event):
 129        self._hide()
 130        self._current_item = None
 131
 132    def _show(self, item: str):
 133        if self._current_item != item or not self._text_fn:
 134            return
 135        text = self._text_fn(item)
 136        if not text:
 137            return
 138
 139        self._tip_window = tw = tk.Toplevel(self._tree)
 140        tw.wm_overrideredirect(True)
 141
 142        x = self._tree.winfo_pointerx() + 15
 143        y = self._tree.winfo_pointery() + 10
 144        tw.wm_geometry(f"+{x}+{y}")
 145
 146        label = tk.Label(tw, text=text, justify=tk.LEFT,
 147                         background="#ffffe0", foreground="#222222",
 148                         relief=tk.SOLID, borderwidth=1, padx=4, pady=2,
 149                         font=("TkDefaultFont", 9))
 150        label.pack()
 151
 152    def _hide(self):
 153        if self._after_id:
 154            self._tree.after_cancel(self._after_id)
 155            self._after_id = None
 156        if self._tip_window:
 157            self._tip_window.destroy()
 158            self._tip_window = None
 159
 160
 161class ActionPanel(tk.Frame):
 162    """Panel for managing grouped action definitions."""
 163
 164    # Treeview item-id prefix for group nodes
 165    _GROUP_PREFIX = "group::"
 166
 167    # Drag threshold in pixels before drag-and-drop starts
 168    _DRAG_THRESHOLD = 8
 169
 170    def __init__(self, parent, on_actions_changed=None, on_export_group=None,
 171                 on_drag_start=None, on_drag_end=None,
 172                 on_before_change=None, get_binding_info=None,
 173                 on_assign_action=None, on_unassign_action=None,
 174                 on_unassign_all=None, get_all_controllers=None,
 175                 get_compatible_inputs=None, is_action_bound=None,
 176                 on_action_renamed=None,
 177                 on_selection_changed=None,
 178                 get_advanced_flags=None,
 179                 icon_loader=None):
 180        """
 181        Args:
 182            parent: tkinter parent widget
 183            on_actions_changed: callback() when any action is added/removed/modified
 184            on_export_group: callback(group_name) when user requests group export
 185            on_drag_start: callback(qname) when an action drag begins
 186            on_drag_end: callback() when a drag ends (release)
 187            on_before_change: callback(coalesce_ms) called BEFORE any mutation,
 188                giving the app a chance to snapshot state for undo
 189            get_binding_info: callback(qname) -> list[(ctrl_name, input_display)]
 190                returns where an action is bound, or empty list if unbound
 191            on_assign_action: callback(qname, port, input_name) to bind action
 192            on_unassign_action: callback(qname, port, input_name) to unbind action
 193            on_unassign_all: callback(qname) to remove action from all inputs
 194            get_all_controllers: callback() -> list[(port, ctrl_name)]
 195            get_compatible_inputs: callback(qname) ->
 196                list[(input_name, display_name)] of compatible inputs
 197            is_action_bound: callback(qname, port, input_name) -> bool
 198            on_action_renamed: callback(old_qname, new_qname) when an action's
 199                qualified name changes (group or name change) so bindings can
 200                be updated
 201            on_selection_changed: callback(qname | None) when tree selection
 202                changes, allowing external listeners to sync
 203        """
 204        super().__init__(parent, padx=5, pady=5)
 205        self._on_actions_changed = on_actions_changed
 206        self._on_export_group = on_export_group
 207        self._on_before_change = on_before_change
 208        self._on_drag_start = on_drag_start
 209        self._on_drag_end = on_drag_end
 210        self._get_binding_info = get_binding_info
 211        self._on_assign_action = on_assign_action
 212        self._on_unassign_action = on_unassign_action
 213        self._on_unassign_all = on_unassign_all
 214        self._get_all_controllers = get_all_controllers
 215        self._get_compatible_inputs = get_compatible_inputs
 216        self._is_action_bound_cb = is_action_bound
 217        self._on_action_renamed = on_action_renamed
 218        self._on_selection_changed = on_selection_changed
 219        self._get_advanced_flags = get_advanced_flags or (
 220            lambda: {"splines": True, "nonmono": True})
 221        self._icon_loader = icon_loader
 222        self._tree_icons: list = []  # Prevent GC of PhotoImage refs
 223        self._details_editable = True
 224        self._actions: dict[str, ActionDefinition] = {}
 225        self._empty_groups: set[str] = set()
 226        self._selected_name: str | None = None
 227        self._updating_form = False  # Guard against feedback loops
 228        self._type_switch_active = False  # True during type-change auto-sets
 229
 230        # Drag-from-tree state
 231        self._drag_item: str | None = None
 232        self._drag_start_pos: tuple[int, int] = (0, 0)
 233        self._drag_started: bool = False
 234        self._drag_target_group: str | None = None
 235        self._drag_highlight_iid: str | None = None
 236
 237        self._build_ui()
 238
 239    # ------------------------------------------------------------------
 240    # UI Construction
 241    # ------------------------------------------------------------------
 242
 243    def _build_ui(self):
 244        # Error styling for invalid field values
 245        style = ttk.Style(self)
 246        style.configure("Error.TEntry", foreground="red")
 247        style.configure("Error.TCombobox", foreground="red")
 248
 249        # --- Action Tree ---
 250        list_frame = ttk.LabelFrame(self, text="Actions", padding=5)
 251        list_frame.pack(fill=tk.BOTH, expand=True)
 252
 253        # Filter entry
 254        filter_frame = tk.Frame(list_frame)
 255        filter_frame.pack(fill=tk.X, pady=(0, 3))
 256        self._filter_var = tk.StringVar()
 257        self._filter_entry = ttk.Entry(
 258            filter_frame, textvariable=self._filter_var, width=20)
 259        self._filter_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
 260        self._filter_var.trace_add("write", self._on_filter_changed)
 261        self._filter_entry.bind(
 262            "<Escape>", lambda e: self._clear_filter())
 263        # Placeholder text
 264        self._filter_placeholder = True
 265        self._filter_entry.insert(0, "Filter actions...")
 266        self._filter_entry.config(foreground="grey")
 267        self._filter_entry.bind("<FocusIn>", self._on_filter_focus_in)
 268        self._filter_entry.bind("<FocusOut>", self._on_filter_focus_out)
 269
 270        # Binding status filter toggles
 271        self._filter_unassigned_var = tk.BooleanVar()
 272        self._filter_multi_var = tk.BooleanVar()
 273        self._filter_unassigned_cb = ttk.Checkbutton(
 274            filter_frame, text="Unassigned",
 275            variable=self._filter_unassigned_var,
 276            command=self._on_status_filter_changed,
 277        )
 278        self._filter_unassigned_cb.pack(side=tk.LEFT, padx=(4, 0))
 279        self._filter_multi_cb = ttk.Checkbutton(
 280            filter_frame, text="Multi",
 281            variable=self._filter_multi_var,
 282            command=self._on_status_filter_changed,
 283        )
 284        self._filter_multi_cb.pack(side=tk.LEFT, padx=(2, 0))
 285
 286        tree_container = tk.Frame(list_frame)
 287        tree_container.pack(fill=tk.BOTH, expand=True)
 288
 289        style = ttk.Style()
 290        style.configure("ActionList.Treeview",
 291                         rowheight=26,
 292                         font=("TkDefaultFont", 10))
 293        self._tree = ttk.Treeview(tree_container, selectmode="browse",
 294                                  show="tree", style="ActionList.Treeview")
 295        self._tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
 296        self._tree.bind("<<TreeviewSelect>>", self._on_select)
 297        self._tree.bind("<<TreeviewOpen>>", self._on_tree_toggle)
 298        self._tree.bind("<<TreeviewClose>>", self._on_tree_toggle)
 299
 300        # Drag-from-tree bindings
 301        self._tree.bind("<ButtonPress-1>", self._on_tree_press)
 302        self._tree.bind("<B1-Motion>", self._on_tree_drag)
 303        self._tree.bind("<ButtonRelease-1>", self._on_tree_release)
 304        self._tree.bind("<MouseWheel>", self._on_tree_scroll)
 305        self._tree.bind("<Button-4>", self._on_tree_scroll)   # Linux scroll up
 306        self._tree.bind("<Button-5>", self._on_tree_scroll)   # Linux scroll down
 307        self._tree.bind("<Delete>", lambda e: self._remove_action())
 308        self._tree.bind("<Control-d>", lambda e: self._duplicate_action())
 309
 310        tree_scroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
 311                                    command=self._tree.yview)
 312        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
 313        self._tree.configure(yscrollcommand=tree_scroll.set)
 314
 315        # Tooltip for action descriptions
 316        self._tooltip = _TreeTooltip(self._tree)
 317        self._tooltip.set_text_fn(self._get_tooltip_text)
 318
 319        # Right-click context menu
 320        self._context_menu = tk.Menu(self, tearoff=0)
 321        self._context_menu.add_command(label="Export Group...",
 322                                       command=self._on_context_export_group)
 323        self._context_menu.add_command(label="Rename Group...",
 324                                       command=self._rename_group)
 325        self._tree.bind("<Button-3>", self._on_right_click)
 326
 327        # Action buttons
 328        btn_frame = tk.Frame(list_frame)
 329        btn_frame.pack(fill=tk.X, pady=(5, 0))
 330        ttk.Button(btn_frame, text="Add", command=self._add_action,
 331                   width=8).pack(side=tk.LEFT, padx=2)
 332        ttk.Button(btn_frame, text="Remove", command=self._remove_action,
 333                   width=8).pack(side=tk.LEFT, padx=2)
 334        ttk.Button(btn_frame, text="Duplicate", command=self._duplicate_action,
 335                   width=8).pack(side=tk.LEFT, padx=2)
 336        self._assign_btn = ttk.Button(
 337            btn_frame, text="Assign...",
 338            command=self._on_assign_button, width=8)
 339        self._assign_btn.pack(side=tk.LEFT, padx=2)
 340
 341        # Group buttons
 342        group_btn_frame = tk.Frame(list_frame)
 343        group_btn_frame.pack(fill=tk.X, pady=(2, 0))
 344        ttk.Button(group_btn_frame, text="Add Group",
 345                   command=self._add_group, width=10).pack(side=tk.LEFT, padx=2)
 346        ttk.Button(group_btn_frame, text="Remove Group",
 347                   command=self._remove_group, width=12).pack(side=tk.LEFT, padx=2)
 348
 349        # --- Detail Editor ---
 350        self._detail_frame = ttk.LabelFrame(self, text="Action Details", padding=5)
 351        self._detail_frame.pack(fill=tk.X, pady=(10, 0))
 352
 353        row = 0
 354
 355        # Name
 356        self._name_label = ttk.Label(self._detail_frame, text="Name:", width=8)
 357        self._name_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 358        self._name_var = tk.StringVar()
 359        self._name_entry = ttk.Entry(self._detail_frame,
 360                                     textvariable=self._name_var, width=20)
 361        self._name_entry.grid(row=row, column=1, sticky=tk.EW, pady=2)
 362        self._name_var.trace_add("write", self._on_name_changed)
 363        self._name_entry.bind("<Return>", self._commit_name)
 364        self._name_entry.bind("<FocusOut>", self._commit_name)
 365
 366        # Group
 367        row += 1
 368        self._group_label = ttk.Label(self._detail_frame, text="Group:", width=8)
 369        self._group_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 370        self._group_var = tk.StringVar()
 371        self._group_combo = ttk.Combobox(self._detail_frame,
 372                                         textvariable=self._group_var, width=17)
 373        self._group_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 374        self._group_var.trace_add("write", self._on_group_changed)
 375        self._group_combo.bind("<Return>", self._commit_group)
 376        self._group_combo.bind("<FocusOut>", self._commit_group)
 377        self._group_combo.bind("<<ComboboxSelected>>", self._commit_group)
 378
 379        # Description (multi-line wrapped text)
 380        row += 1
 381        self._desc_label = ttk.Label(self._detail_frame, text="Description:",
 382                                         width=12)
 383        self._desc_label.grid(row=row, column=0, sticky=tk.NW, pady=2)
 384        self._desc_text = tk.Text(self._detail_frame, width=23, height=3,
 385                                  wrap=tk.WORD, font=("TkDefaultFont", 9),
 386                                  relief=tk.SUNKEN, borderwidth=1)
 387        self._desc_text.grid(row=row, column=1, sticky=tk.EW, pady=2)
 388        self._desc_text.bind("<<Modified>>", self._on_desc_modified)
 389
 390        # Input Type
 391        row += 1
 392        self._input_type_label = ttk.Label(self._detail_frame, text="Input Type:",
 393                                                 width=8, wraplength=55)
 394        self._input_type_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 395        self._input_type_var = tk.StringVar()
 396        self._input_type_combo = ttk.Combobox(
 397            self._detail_frame, textvariable=self._input_type_var,
 398            values=[t.value for t in InputType], state="readonly", width=17,
 399        )
 400        self._input_type_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 401        self._input_type_var.trace_add("write", self._on_input_type_changed)
 402
 403        # Trigger Mode
 404        row += 1
 405        self._trigger_label = ttk.Label(self._detail_frame, text="Trigger Mode:")
 406        self._trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 407        self._trigger_var = tk.StringVar()
 408        self._trigger_combo = ttk.Combobox(
 409            self._detail_frame, textvariable=self._trigger_var,
 410            values=[t.value for t in BUTTON_EVENT_TRIGGER_MODES],
 411            state="readonly", width=17,
 412        )
 413        self._trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 414        self._trigger_var.trace_add("write", self._on_field_changed)
 415        self._trigger_var.trace_add("write", self._check_spline_gate)
 416
 417        # Threshold (boolean_trigger; greyed out for button)
 418        row += 1
 419        self._threshold_label = ttk.Label(
 420            self._detail_frame, text="Threshold:")
 421        self._threshold_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 422        self._threshold_var = tk.StringVar(value="0.5")
 423        self._threshold_spin = ttk.Spinbox(
 424            self._detail_frame, textvariable=self._threshold_var,
 425            from_=0.0, to=1.0, increment=0.05, width=17,
 426        )
 427        self._threshold_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 428        self._threshold_var.trace_add("write", self._on_field_changed)
 429
 430        # Deadband (axis only)
 431        row += 1
 432        self._deadband_label = ttk.Label(self._detail_frame, text="Deadband:")
 433        self._deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 434        self._deadband_var = tk.StringVar(value="0.0")
 435        self._deadband_spin = ttk.Spinbox(
 436            self._detail_frame, textvariable=self._deadband_var,
 437            from_=0.0, to=1.0, increment=0.01, width=17,
 438        )
 439        self._deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 440        self._deadband_var.trace_add("write", self._on_field_changed)
 441
 442        # Inversion (axis only)
 443        row += 1
 444        self._inversion_label = ttk.Label(self._detail_frame, text="Inversion:")
 445        self._inversion_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 446        self._inversion_var = tk.BooleanVar()
 447        self._inversion_check = ttk.Checkbutton(
 448            self._detail_frame, variable=self._inversion_var,
 449        )
 450        self._inversion_check.grid(row=row, column=1, sticky=tk.W, pady=2)
 451        self._inversion_var.trace_add("write", self._on_field_changed)
 452
 453        # Scale (axis only)
 454        row += 1
 455        self._scale_label = ttk.Label(self._detail_frame, text="Scale:")
 456        self._scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 457        self._scale_var = tk.StringVar(value="1.0")
 458        self._scale_spin = ttk.Spinbox(
 459            self._detail_frame, textvariable=self._scale_var,
 460            from_=-10.0, to=10.0, increment=0.1, width=17,
 461        )
 462        self._scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 463        self._scale_var.trace_add("write", self._on_field_changed)
 464
 465        # Slew rate (axis only)
 466        row += 1
 467        self._slew_label = ttk.Label(self._detail_frame, text="Slew Rate:")
 468        self._slew_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 469        self._slew_var = tk.StringVar(value="0.0")
 470        self._slew_spin = ttk.Spinbox(
 471            self._detail_frame, textvariable=self._slew_var,
 472            from_=0.0, to=100.0, increment=0.1, width=17,
 473        )
 474        self._slew_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 475        self._slew_var.trace_add("write", self._on_field_changed)
 476
 477        # Negative slew rate (axis only, stored in extra)
 478        row += 1
 479        self._neg_slew_frame = ttk.Frame(self._detail_frame)
 480        self._neg_slew_frame.grid(row=row, column=0, columnspan=2,
 481                                  sticky=tk.EW, pady=2)
 482        self._neg_slew_enable_var = tk.BooleanVar(value=False)
 483        self._neg_slew_check = ttk.Checkbutton(
 484            self._neg_slew_frame, text="Neg. Slew Rate:",
 485            variable=self._neg_slew_enable_var,
 486        )
 487        self._neg_slew_check.pack(side=tk.LEFT)
 488        self._neg_slew_var = tk.StringVar(value="0.0")
 489        self._neg_slew_spin = ttk.Spinbox(
 490            self._neg_slew_frame, textvariable=self._neg_slew_var,
 491            from_=-100.0, to=0.0, increment=0.1, width=10,
 492        )
 493        self._neg_slew_spin.pack(side=tk.LEFT, fill=tk.X, expand=True)
 494        self._neg_slew_spin.config(state="disabled")
 495        self._neg_slew_enable_var.trace_add("write", self._on_neg_slew_toggled)
 496        self._neg_slew_var.trace_add("write", self._on_field_changed)
 497
 498        # Spline controls (visible only for ANALOG + SPLINE trigger mode)
 499        row += 1
 500        self._edit_spline_btn = ttk.Button(
 501            self._detail_frame, text="Edit Spline...",
 502            command=self._on_edit_spline,
 503        )
 504        self._edit_spline_btn.grid(row=row, column=0, columnspan=2,
 505                                    sticky=tk.EW, pady=2)
 506
 507        # Segment controls (visible only for ANALOG + SEGMENTED trigger mode)
 508        row += 1
 509        self._edit_segments_btn = ttk.Button(
 510            self._detail_frame, text="Edit Segments...",
 511            command=self._on_edit_segments,
 512        )
 513        self._edit_segments_btn.grid(row=row, column=0, columnspan=2,
 514                                     sticky=tk.EW, pady=2)
 515
 516        # --- Virtual Analog fields (visible only for VIRTUAL_ANALOG) ---
 517        # Compact 4-column grid inside a single frame to save vertical space.
 518
 519        row += 1
 520        self._va_outer_frame = ttk.Frame(self._detail_frame)
 521        self._va_outer_frame.grid(row=row, column=0, columnspan=2,
 522                                  sticky=tk.EW, pady=2)
 523        vr = 0  # row counter inside VA frame
 524
 525        # Row 0: Button Mode
 526        ttk.Label(self._va_outer_frame, text="Mode:").grid(
 527            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 528        self._va_mode_var = tk.StringVar(value="held")
 529        self._va_mode_combo = ttk.Combobox(
 530            self._va_outer_frame, textvariable=self._va_mode_var,
 531            values=["held", "toggle"], state="readonly", width=7,
 532        )
 533        self._va_mode_combo.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 534        self._va_mode_var.trace_add("write", self._on_field_changed)
 535
 536        # Row 1: Ramp Rate | Acceleration (mutually exclusive)
 537        vr += 1
 538        ttk.Label(self._va_outer_frame, text="Ramp Rate:").grid(
 539            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 540        self._va_ramp_var = tk.StringVar(value="1.0")
 541        self._va_ramp_spin = ttk.Spinbox(
 542            self._va_outer_frame, textvariable=self._va_ramp_var,
 543            from_=0.0, to=100.0, increment=0.1, width=7,
 544        )
 545        self._va_ramp_spin.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 546        self._va_ramp_var.trace_add("write", self._on_field_changed)
 547
 548        ttk.Label(self._va_outer_frame, text="Accel:").grid(
 549            row=vr, column=2, sticky=tk.W, padx=(0, 2))
 550        self._va_accel_var = tk.StringVar(value="0.0")
 551        self._va_accel_spin = ttk.Spinbox(
 552            self._va_outer_frame, textvariable=self._va_accel_var,
 553            from_=0.0, to=100.0, increment=0.1, width=7,
 554        )
 555        self._va_accel_spin.grid(row=vr, column=3, sticky=tk.EW)
 556        self._va_accel_var.trace_add("write", self._on_field_changed)
 557
 558        # Row 1: Target | Rest
 559        vr += 1
 560        ttk.Label(self._va_outer_frame, text="Target:").grid(
 561            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 562        self._va_target_var = tk.StringVar(value="1.0")
 563        self._va_target_spin = ttk.Spinbox(
 564            self._va_outer_frame, textvariable=self._va_target_var,
 565            from_=-10.0, to=10.0, increment=0.1, width=7,
 566        )
 567        self._va_target_spin.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 568        self._va_target_var.trace_add("write", self._on_field_changed)
 569
 570        ttk.Label(self._va_outer_frame, text="Rest:").grid(
 571            row=vr, column=2, sticky=tk.W, padx=(0, 2))
 572        self._va_rest_var = tk.StringVar(value="0.0")
 573        self._va_rest_spin = ttk.Spinbox(
 574            self._va_outer_frame, textvariable=self._va_rest_var,
 575            from_=-10.0, to=10.0, increment=0.1, width=7,
 576        )
 577        self._va_rest_spin.grid(row=vr, column=3, sticky=tk.EW)
 578        self._va_rest_var.trace_add("write", self._on_field_changed)
 579
 580        # Row 2: Zero Vel on Release checkbox
 581        vr += 1
 582        self._va_zero_vel_var = tk.BooleanVar(value=False)
 583        self._va_zero_vel_check = ttk.Checkbutton(
 584            self._va_outer_frame, text="Zero vel on release",
 585            variable=self._va_zero_vel_var,
 586        )
 587        self._va_zero_vel_check.grid(
 588            row=vr, column=0, columnspan=4, sticky=tk.W, pady=2)
 589        self._va_zero_vel_var.trace_add("write", self._on_field_changed)
 590
 591        # Row 3: Neg Ramp Rate | Neg Acceleration (optional overrides)
 592        vr += 1
 593        self._va_neg_ramp_enable_var = tk.BooleanVar(value=False)
 594        self._va_neg_ramp_check = ttk.Checkbutton(
 595            self._va_outer_frame, text="Neg Ramp:",
 596            variable=self._va_neg_ramp_enable_var,
 597        )
 598        self._va_neg_ramp_check.grid(row=vr, column=0, sticky=tk.W)
 599        self._va_neg_ramp_var = tk.StringVar(value="0.0")
 600        self._va_neg_ramp_spin = ttk.Spinbox(
 601            self._va_outer_frame, textvariable=self._va_neg_ramp_var,
 602            from_=0.0, to=100.0, increment=0.1, width=7,
 603        )
 604        self._va_neg_ramp_spin.grid(
 605            row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 606        self._va_neg_ramp_spin.config(state="disabled")
 607        self._va_neg_ramp_enable_var.trace_add(
 608            "write", self._on_va_neg_ramp_toggled)
 609        self._va_neg_ramp_var.trace_add("write", self._on_field_changed)
 610
 611        self._va_neg_accel_enable_var = tk.BooleanVar(value=False)
 612        self._va_neg_accel_check = ttk.Checkbutton(
 613            self._va_outer_frame, text="Neg Accel:",
 614            variable=self._va_neg_accel_enable_var,
 615        )
 616        self._va_neg_accel_check.grid(row=vr, column=2, sticky=tk.W)
 617        self._va_neg_accel_var = tk.StringVar(value="0.0")
 618        self._va_neg_accel_spin = ttk.Spinbox(
 619            self._va_outer_frame, textvariable=self._va_neg_accel_var,
 620            from_=0.0, to=100.0, increment=0.1, width=7,
 621        )
 622        self._va_neg_accel_spin.grid(row=vr, column=3, sticky=tk.EW)
 623        self._va_neg_accel_spin.config(state="disabled")
 624        self._va_neg_accel_enable_var.trace_add(
 625            "write", self._on_va_neg_accel_toggled)
 626        self._va_neg_accel_var.trace_add("write", self._on_field_changed)
 627
 628        # Let spinbox columns stretch
 629        self._va_outer_frame.columnconfigure(1, weight=1)
 630        self._va_outer_frame.columnconfigure(3, weight=1)
 631
 632        self._detail_frame.columnconfigure(1, weight=1)
 633
 634        # Analog-only widgets for show/hide
 635        self._axis_widgets = [
 636            (self._deadband_label, self._deadband_spin),
 637            (self._inversion_label, self._inversion_check),
 638            (self._scale_label, self._scale_spin),
 639            (self._slew_label, self._slew_spin),
 640        ]
 641        # Neg slew frame handled separately (single frame spanning both columns)
 642        self._neg_slew_widgets = [self._neg_slew_frame]
 643
 644        # Spline-only widgets for show/hide
 645        self._spline_widgets = [self._edit_spline_btn]
 646        self._edit_spline_btn.grid_remove()
 647
 648        # Segment-only widgets for show/hide
 649        self._segment_widgets = [self._edit_segments_btn]
 650        self._edit_segments_btn.grid_remove()
 651
 652        # Initially hide VA frame
 653        self._va_outer_frame.grid_remove()
 654
 655        # Tooltips for detail form fields
 656        _WidgetTooltip(self._name_label, TIP_NAME)
 657        _WidgetTooltip(self._name_entry, TIP_NAME)
 658        _WidgetTooltip(self._group_label, TIP_GROUP)
 659        _WidgetTooltip(self._group_combo, TIP_GROUP)
 660        _WidgetTooltip(self._desc_label, TIP_DESC)
 661        _WidgetTooltip(self._desc_text, TIP_DESC)
 662        _WidgetTooltip(self._input_type_label, TIP_INPUT_TYPE)
 663        _WidgetTooltip(self._input_type_combo, TIP_INPUT_TYPE)
 664        self._trigger_tooltip = _WidgetTooltip(
 665            self._trigger_label, TIP_TRIGGER)
 666        _WidgetTooltip(self._trigger_combo, TIP_TRIGGER)
 667        _WidgetTooltip(self._threshold_label, TIP_THRESHOLD)
 668        _WidgetTooltip(self._threshold_spin, TIP_THRESHOLD)
 669        _WidgetTooltip(self._deadband_label, TIP_DEADBAND)
 670        _WidgetTooltip(self._deadband_spin, TIP_DEADBAND)
 671        _WidgetTooltip(self._inversion_label, TIP_INVERSION)
 672        _WidgetTooltip(self._inversion_check, TIP_INVERSION)
 673        _WidgetTooltip(self._scale_label, TIP_SCALE)
 674        _WidgetTooltip(self._scale_spin, TIP_SCALE)
 675        _WidgetTooltip(self._slew_label, TIP_SLEW)
 676        _WidgetTooltip(self._slew_spin, TIP_SLEW)
 677        _WidgetTooltip(self._neg_slew_check, TIP_NEG_SLEW)
 678        _WidgetTooltip(self._neg_slew_spin, TIP_NEG_SLEW)
 679        _WidgetTooltip(self._edit_spline_btn, TIP_EDIT_SPLINE)
 680        _WidgetTooltip(self._edit_segments_btn, TIP_EDIT_SEGMENTS)
 681        # VA field tooltips
 682        _WidgetTooltip(self._va_mode_combo, TIP_VA_BUTTON_MODE)
 683        _WidgetTooltip(self._va_ramp_spin, TIP_VA_RAMP_RATE)
 684        _WidgetTooltip(self._va_accel_spin, TIP_VA_ACCELERATION)
 685        _WidgetTooltip(self._va_target_spin, TIP_VA_TARGET)
 686        _WidgetTooltip(self._va_rest_spin, TIP_VA_REST)
 687        _WidgetTooltip(self._va_zero_vel_check, TIP_VA_ZERO_VEL)
 688        _WidgetTooltip(self._va_neg_ramp_check, TIP_VA_NEG_RAMP)
 689        _WidgetTooltip(self._va_neg_ramp_spin, TIP_VA_NEG_RAMP)
 690        _WidgetTooltip(self._va_neg_accel_check, TIP_VA_NEG_ACCEL)
 691        _WidgetTooltip(self._va_neg_accel_spin, TIP_VA_NEG_ACCEL)
 692        # Filter bar tooltips
 693        _WidgetTooltip(self._filter_entry, TIP_FILTER)
 694        _WidgetTooltip(self._filter_unassigned_cb, TIP_FILTER_UNASSIGNED)
 695        _WidgetTooltip(self._filter_multi_cb, TIP_FILTER_MULTI)
 696
 697        self._set_detail_enabled(False)
 698
 699    # ------------------------------------------------------------------
 700    # Custom-settings tracking
 701    # ------------------------------------------------------------------
 702
 703    @staticmethod
 704    def _is_action_custom(action: ActionDefinition) -> bool:
 705        """Check if an action has any non-default field values.
 706
 707        Used on load to tag actions that were customized in the YAML.
 708        """
 709        if action.input_type == InputType.ANALOG:
 710            default_trigger = EventTriggerMode.SCALED
 711        else:
 712            default_trigger = EventTriggerMode.ON_TRUE
 713        return (
 714            action.deadband > 0.01
 715            or action.inversion
 716            or abs(action.scale - 1.0) > 0.01
 717            or action.slew_rate > 0.01
 718            or action.trigger_mode != default_trigger
 719            or action.extra.get(EXTRA_SPLINE_POINTS)
 720            or action.extra.get(EXTRA_SEGMENT_POINTS)
 721            or action.extra.get(EXTRA_NEGATIVE_SLEW_RATE) is not None
 722        )
 723
 724    def _tag_actions_custom(self):
 725        """Set _has_custom on all actions from current field values."""
 726        for action in self._actions.values():
 727            action._has_custom = self._is_action_custom(action)
 728
 729    # ------------------------------------------------------------------
 730    # Public API
 731    # ------------------------------------------------------------------
 732
 733    def set_details_editable(self, enabled: bool):
 734        """Enable or disable editing of Action Details fields.
 735
 736        When disabled, all detail form fields become display-only.
 737        """
 738        self._details_editable = enabled
 739        if self._selected_name:
 740            self._apply_details_editable()
 741
 742    def _apply_details_editable(self):
 743        """Apply the current _details_editable state to detail widgets."""
 744        # Re-run _set_detail_enabled which checks _details_editable
 745        self._set_detail_enabled(True)
 746
 747    def on_advanced_changed(self):
 748        """Refresh UI elements affected by Advanced menu toggles."""
 749        if self._selected_name:
 750            action = self._actions.get(self._selected_name)
 751            if action and action.input_type == InputType.ANALOG:
 752                self._refresh_spline_gate()
 753
 754    def set_actions(self, actions: dict[str, ActionDefinition]):
 755        """Load a full set of actions (e.g., from file)."""
 756        self._actions = dict(actions)
 757        self._tag_actions_custom()
 758        self._empty_groups = set()
 759        self._refresh_tree()
 760        self._selected_name = None
 761        self._set_detail_enabled(False)
 762
 763    def get_actions(self) -> dict[str, ActionDefinition]:
 764        """Return the current actions dict keyed by qualified name."""
 765        return dict(self._actions)
 766
 767    def get_action_names(self) -> list[str]:
 768        """Return sorted list of fully qualified action names."""
 769        return sorted(self._actions.keys())
 770
 771    def get_empty_groups(self) -> set[str]:
 772        """Return a copy of the empty-group set (for undo snapshots)."""
 773        return set(self._empty_groups)
 774
 775    def set_empty_groups(self, groups: set[str]):
 776        """Restore the empty-group set (for undo restore)."""
 777        self._empty_groups = set(groups)
 778        self._refresh_tree()
 779
 780    # ------------------------------------------------------------------
 781    # Tree Management
 782    # ------------------------------------------------------------------
 783
 784    def _collect_groups(self) -> dict[str, list[str]]:
 785        """Collect group -> [qualified_name, ...] mapping.
 786
 787        The "general" group is always included so actions can be
 788        assigned to it even when it has no members.
 789        """
 790        groups: dict[str, list[str]] = {DEFAULT_GROUP: []}
 791        for qname, action in self._actions.items():
 792            groups.setdefault(action.group, []).append(qname)
 793        for g in self._empty_groups:
 794            if g not in groups:
 795                groups[g] = []
 796        return groups
 797
 798    def _sorted_group_names(self, groups: dict[str, list[str]]) -> list[str]:
 799        """Sort group names with 'general' first."""
 800        return sorted(groups.keys(), key=lambda g: (g != DEFAULT_GROUP, g))
 801
 802    def get_group_names(self) -> list[str]:
 803        """Return sorted list of all group names (including empty/default).
 804
 805        Single source of truth for group names used by both the
 806        ActionPanel and ActionEditorTab group dropdowns.
 807        """
 808        return self._sorted_group_names(self._collect_groups())
 809
 810    # Prefix for placeholder items in empty groups
 811    _EMPTY_PREFIX = "empty::"
 812
 813    def _refresh_tree(self):
 814        """Rebuild the treeview from the actions dict."""
 815        self._tree.delete(*self._tree.get_children())
 816
 817        groups = self._collect_groups()
 818        sorted_groups = self._sorted_group_names(groups)
 819        filt = self._get_filter_text()
 820        status_active = (self._filter_unassigned_var.get()
 821                         or self._filter_multi_var.get())
 822
 823        for group in sorted_groups:
 824            group_iid = f"{self._GROUP_PREFIX}{group}"
 825            members = groups[group]
 826
 827            # Apply text filter: keep actions matching name/group/description
 828            if filt:
 829                members = [
 830                    q for q in members
 831                    if self._matches_filter(q, filt)
 832                ]
 833                # Skip groups with no matching actions (unless group
 834                # name itself matches)
 835                if not members and filt not in group.lower():
 836                    continue
 837
 838            # Apply binding status filter
 839            if status_active:
 840                members = [
 841                    q for q in members
 842                    if self._matches_status_filter(q)
 843                ]
 844                if not members:
 845                    continue
 846
 847            has_actions = bool(members)
 848            self._tree.insert("", tk.END, iid=group_iid,
 849                              text=f" {group}", open=has_actions,
 850                              tags=("group",))
 851
 852            if has_actions:
 853                for qname in sorted(members,
 854                                    key=lambda q: q.split(".", 1)[-1]):
 855                    action = self._actions[qname]
 856                    self._tree.insert(group_iid, tk.END, iid=qname,
 857                                      text=f"  {action.name}",
 858                                      tags=("action",))
 859            else:
 860                # Placeholder so the +/- indicator appears
 861                self._tree.insert(
 862                    group_iid, tk.END,
 863                    iid=f"{self._EMPTY_PREFIX}{group}",
 864                    text="  (empty)", tags=("empty_placeholder",))
 865
 866        # Update group combo values
 867        self._group_combo['values'] = sorted_groups
 868
 869        # Style rows
 870        self._tree.tag_configure("group", font=("TkDefaultFont", 10, "bold"))
 871        self._tree.tag_configure("action", font=("TkDefaultFont", 10))
 872        self._tree.tag_configure("empty_placeholder",
 873                                 foreground="#999999",
 874                                 font=("TkDefaultFont", 10, "italic"))
 875
 876        self.update_binding_tags()
 877
 878    def update_binding_tags(self):
 879        """Update action item background colors based on binding status.
 880
 881        Called after bindings change (drag-drop, dialog, undo, file load).
 882        - Unassigned actions get a faint red background.
 883        - Actions bound to more than one input get a faint yellow background.
 884        - Collapsed groups reflect child status: red (unassigned), yellow
 885          (duplicate-bound), or orange (both).
 886        """
 887        self._tree.tag_configure("unassigned",
 888                                 background="#ffdddd",
 889                                 font=("TkDefaultFont", 10))
 890        self._tree.tag_configure("multi_bound",
 891                                 background="#ffffcc",
 892                                 font=("TkDefaultFont", 10))
 893        self._tree.tag_configure("action",
 894                                 background="",
 895                                 font=("TkDefaultFont", 10))
 896        # Group-level status tags (shown when collapsed)
 897        self._tree.tag_configure("group_unassigned",
 898                                 background="#ffdddd",
 899                                 font=("TkDefaultFont", 10, "bold"))
 900        self._tree.tag_configure("group_multi_bound",
 901                                 background="#ffffcc",
 902                                 font=("TkDefaultFont", 10, "bold"))
 903        self._tree.tag_configure("group_mixed",
 904                                 background="#ffddbb",
 905                                 font=("TkDefaultFont", 10, "bold"))
 906
 907        if not self._get_binding_info:
 908            return
 909
 910        # Track per-group status flags
 911        group_has_unassigned: dict[str, bool] = {}
 912        group_has_multi: dict[str, bool] = {}
 913
 914        # Clear old icon refs before rebuilding
 915        self._tree_icons.clear()
 916
 917        for qname, action in self._actions.items():
 918            if not self._tree.exists(qname):
 919                continue
 920            bindings = self._get_binding_info(qname)
 921            if not bindings:
 922                self._tree.item(qname, tags=("unassigned",))
 923                group_has_unassigned[action.group] = True
 924            elif len(bindings) > 1:
 925                self._tree.item(qname, tags=("multi_bound",))
 926                group_has_multi[action.group] = True
 927            else:
 928                self._tree.item(qname, tags=("action",))
 929
 930            # Set icon from first binding's input name
 931            icon = None
 932            if bindings and self._icon_loader:
 933                input_name = bindings[0][2]  # (ctrl, display, input_name)
 934                icon = self._icon_loader.get_tk_icon(input_name, 20)
 935            if icon:
 936                self._tree_icons.append(icon)
 937                self._tree.item(qname, image=icon)
 938            else:
 939                self._tree.item(qname, image="")
 940
 941        # Apply status colors to collapsed group nodes
 942        self._update_group_tags(group_has_unassigned, group_has_multi)
 943
 944    def _update_group_tags(self, group_has_unassigned: dict[str, bool],
 945                           group_has_multi: dict[str, bool]):
 946        """Set group node tags based on child status and collapsed state."""
 947        for group_iid in self._tree.get_children(""):
 948            if not group_iid.startswith(self._GROUP_PREFIX):
 949                continue
 950            group_name = group_iid[len(self._GROUP_PREFIX):]
 951            is_open = self._tree.item(group_iid, "open")
 952            has_unassigned = group_has_unassigned.get(group_name, False)
 953            has_multi = group_has_multi.get(group_name, False)
 954
 955            if not is_open and has_unassigned and has_multi:
 956                self._tree.item(group_iid, tags=("group_mixed",))
 957            elif not is_open and has_unassigned:
 958                self._tree.item(group_iid, tags=("group_unassigned",))
 959            elif not is_open and has_multi:
 960                self._tree.item(group_iid, tags=("group_multi_bound",))
 961            else:
 962                self._tree.item(group_iid, tags=("group",))
 963
 964    def _on_tree_toggle(self, event):
 965        """Handle group expand/collapse — refresh group background colors."""
 966        self.update_binding_tags()
 967
 968    # ------------------------------------------------------------------
 969    # Filter
 970    # ------------------------------------------------------------------
 971
 972    def _get_filter_text(self) -> str:
 973        """Return the active filter string, or '' if placeholder is showing."""
 974        if self._filter_placeholder:
 975            return ""
 976        return self._filter_var.get().strip().lower()
 977
 978    def _on_filter_changed(self, *args):
 979        if self._filter_placeholder:
 980            return
 981        self._refresh_tree()
 982
 983    def _clear_filter(self):
 984        self._filter_var.set("")
 985        self._filter_unassigned_var.set(False)
 986        self._filter_multi_var.set(False)
 987        self._refresh_tree()
 988        self._tree.focus_set()
 989
 990    def _on_filter_focus_in(self, event):
 991        if self._filter_placeholder:
 992            self._filter_placeholder = False
 993            self._filter_entry.delete(0, tk.END)
 994            self._filter_entry.config(foreground="")
 995
 996    def _on_filter_focus_out(self, event):
 997        if not self._filter_var.get():
 998            self._filter_placeholder = True
 999            self._filter_entry.insert(0, "Filter actions...")
1000            self._filter_entry.config(foreground="grey")
1001
1002    def _on_status_filter_changed(self):
1003        """Handle unassigned/multi-bound filter toggle."""
1004        self._refresh_tree()
1005
1006    def _matches_filter(self, qname: str, filt: str) -> bool:
1007        """Check if an action matches the filter text.
1008
1009        Supports glob wildcards (* and ?) when present in the filter.
1010        Falls back to substring matching otherwise.
1011        """
1012        action = self._actions.get(qname)
1013        if not action:
1014            return False
1015        fields = (action.name.lower(), action.group.lower(),
1016                  action.description.lower())
1017        if '*' in filt or '?' in filt:
1018            # Auto-append * so users don't need to match through
1019            # end of string: "de*p" matches "deploy"
1020            pattern = filt if filt.endswith(('*', '?')) else filt + '*'
1021            return any(fnmatch.fnmatch(f, pattern) for f in fields)
1022        return any(filt in f for f in fields)
1023
1024    def _matches_status_filter(self, qname: str) -> bool:
1025        """Check if an action passes the binding status filter.
1026
1027        When neither toggle is active, all actions pass.
1028        When one or both are active, the action must match at least
1029        one active filter (OR logic).
1030        """
1031        want_unassigned = self._filter_unassigned_var.get()
1032        want_multi = self._filter_multi_var.get()
1033        if not want_unassigned and not want_multi:
1034            return True
1035        if not self._get_binding_info:
1036            return True
1037        bindings = self._get_binding_info(qname)
1038        if want_unassigned and not bindings:
1039            return True
1040        if want_multi and len(bindings) > 1:
1041            return True
1042        return False
1043
1044    # ------------------------------------------------------------------
1045    # Selection Handling
1046    # ------------------------------------------------------------------
1047
1048    def _on_select(self, event):
1049        """Handle tree selection change."""
1050        sel = self._tree.selection()
1051        if not sel:
1052            self._selected_name = None
1053            self._set_detail_enabled(False)
1054            self._notify_selection_changed()
1055            return
1056
1057        item_id = sel[0]
1058        if (item_id.startswith(self._GROUP_PREFIX)
1059                or item_id.startswith(self._EMPTY_PREFIX)):
1060            self._selected_name = None
1061            self._set_detail_enabled(False)
1062            self._notify_selection_changed()
1063            return
1064
1065        self._selected_name = item_id
1066        self._load_detail(item_id)
1067        self._set_detail_enabled(True)
1068        self._notify_selection_changed()
1069
1070    def _notify_selection_changed(self):
1071        """Notify external listeners of the current selection."""
1072        if self._on_selection_changed:
1073            self._on_selection_changed(self._selected_name)
1074
1075    def _load_detail(self, qname: str):
1076        """Populate the detail form from an action."""
1077        action = self._actions.get(qname)
1078        if not action:
1079            return
1080
1081        self._updating_form = True
1082        try:
1083            self._name_var.set(action.name)
1084            self._group_var.set(action.group)
1085            self._desc_text.delete("1.0", tk.END)
1086            self._desc_text.insert("1.0", action.description)
1087            self._desc_text.edit_modified(False)
1088            self._input_type_var.set(action.input_type.value)
1089            self._update_trigger_mode_options(action.input_type)
1090            self._trigger_var.set(action.trigger_mode.value)
1091            self._threshold_var.set(str(action.threshold))
1092            self._deadband_var.set(str(action.deadband))
1093            self._inversion_var.set(action.inversion)
1094            self._scale_var.set(str(action.scale))
1095            self._slew_var.set(str(action.slew_rate))
1096            neg_slew = action.extra.get(EXTRA_NEGATIVE_SLEW_RATE)
1097            if neg_slew is not None:
1098                self._neg_slew_enable_var.set(True)
1099                self._neg_slew_var.set(str(min(float(neg_slew), 0.0)))
1100            else:
1101                self._neg_slew_enable_var.set(False)
1102                self._neg_slew_var.set("0.0")
1103            # Virtual Analog fields
1104            self._va_mode_var.set(
1105                action.extra.get(EXTRA_VA_BUTTON_MODE, "held"))
1106            self._va_ramp_var.set(
1107                str(action.extra.get(EXTRA_VA_RAMP_RATE, 0.0)))
1108            self._va_accel_var.set(
1109                str(action.extra.get(EXTRA_VA_ACCELERATION, 0.0)))
1110            self._va_target_var.set(
1111                str(action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0)))
1112            self._va_rest_var.set(
1113                str(action.extra.get(EXTRA_VA_REST_VALUE, 0.0)))
1114            self._va_zero_vel_var.set(
1115                bool(action.extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False)))
1116            neg_ramp = action.extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
1117            if neg_ramp is not None:
1118                self._va_neg_ramp_enable_var.set(True)
1119                self._va_neg_ramp_var.set(str(float(neg_ramp)))
1120            else:
1121                self._va_neg_ramp_enable_var.set(False)
1122                self._va_neg_ramp_var.set("0.0")
1123            neg_accel = action.extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
1124            if neg_accel is not None:
1125                self._va_neg_accel_enable_var.set(True)
1126                self._va_neg_accel_var.set(str(float(neg_accel)))
1127            else:
1128                self._va_neg_accel_enable_var.set(False)
1129                self._va_neg_accel_var.set("0.0")
1130        finally:
1131            self._updating_form = False
1132
1133        self._update_type_visibility()
1134
1135    def _set_detail_enabled(self, enabled: bool):
1136        """Enable or disable the detail form.
1137
1138        When *enabled* is True but ``_details_editable`` is False, the form
1139        fields remain disabled (display-only).
1140        """
1141        # If the form is being enabled but detail editing is locked,
1142        # force display-only mode.
1143        effective = enabled and self._details_editable
1144        state = "normal" if effective else "disabled"
1145        readonly_state = "readonly" if effective else "disabled"
1146        self._name_entry.config(state=state)
1147        self._group_combo.config(state=state)
1148        for child in self._detail_frame.winfo_children():
1149            if isinstance(child, (ttk.Entry, ttk.Spinbox)):
1150                child.config(state=state)
1151            elif isinstance(child, ttk.Combobox):
1152                child.config(state=readonly_state)
1153            elif isinstance(child, (ttk.Checkbutton, ttk.Button)):
1154                child.config(state=state)
1155        # Handle children inside nested sub-frames
1156        for frame in (self._neg_slew_frame,
1157                      self._va_outer_frame):
1158            for child in frame.winfo_children():
1159                if isinstance(child, (ttk.Spinbox, ttk.Checkbutton)):
1160                    child.config(state=state)
1161        # The group combo is editable (not readonly) so users can type new names
1162        if effective:
1163            self._group_combo.config(state=state)
1164        # Also update desc text widget (not a ttk widget, skipped above)
1165        self._desc_text.config(
1166            state="normal" if effective else "disabled")
1167
1168    def _update_trigger_mode_options(self, input_type: InputType):
1169        """Update the trigger mode dropdown to show modes for the current input type.
1170
1171        Output actions have no trigger mode — the row is hidden instead.
1172        """
1173        if input_type == InputType.OUTPUT:
1174            # Hide trigger mode entirely for outputs
1175            self._trigger_label.grid_remove()
1176            self._trigger_combo.grid_remove()
1177            return
1178
1179        # Ensure trigger row is visible
1180        self._trigger_label.grid()
1181        self._trigger_combo.grid()
1182
1183        if input_type in (InputType.ANALOG, InputType.VIRTUAL_ANALOG):
1184            modes = ANALOG_EVENT_TRIGGER_MODES
1185            default = EventTriggerMode.SCALED
1186        else:
1187            modes = BUTTON_EVENT_TRIGGER_MODES
1188            default = EventTriggerMode.ON_TRUE
1189
1190        current = self._trigger_var.get()
1191        flags = self._get_advanced_flags()
1192        values = []
1193        for m in modes:
1194            label = m.value
1195            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1196                    and current != EventTriggerMode.SPLINE.value):
1197                label += _SPLINE_ADV_SUFFIX
1198            values.append(label)
1199        self._trigger_combo['values'] = values
1200
1201        # If current selection isn't valid for the new type, reset to default
1202        clean_values = [m.value for m in modes]
1203        if current not in clean_values:
1204            self._trigger_var.set(default.value)
1205
1206    def _refresh_spline_gate(self):
1207        """Update trigger combo values to reflect current spline gate state."""
1208        flags = self._get_advanced_flags()
1209        current = self._trigger_var.get()
1210        values = []
1211        for m in ANALOG_EVENT_TRIGGER_MODES:
1212            label = m.value
1213            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1214                    and current != EventTriggerMode.SPLINE.value):
1215                label += _SPLINE_ADV_SUFFIX
1216            values.append(label)
1217        self._trigger_combo['values'] = values
1218
1219    def _check_spline_gate(self, *args):
1220        """Revert spline selection if splines are disabled."""
1221        if self._updating_form:
1222            return
1223        val = self._trigger_var.get()
1224        if val.endswith(_SPLINE_ADV_SUFFIX):
1225            action = (self._actions.get(self._selected_name)
1226                      if self._selected_name else None)
1227            self._updating_form = True
1228            self._trigger_var.set(
1229                action.trigger_mode.value
1230                if action else EventTriggerMode.SCALED.value)
1231            self._updating_form = False
1232            messagebox.showinfo(
1233                "Advanced Feature",
1234                "Enable splines in Advanced menu to use this mode.")
1235
1236    def _update_type_visibility(self):
1237        """Show/hide fields based on input type and trigger mode.
1238
1239        Analog-specific fields (deadband, inversion, scale, slew) only shown
1240        for analog.  When trigger mode is RAW, axis fields are visible but
1241        disabled (greyed out) since RAW bypasses all shaping.
1242        Spline controls only shown for analog + spline trigger mode.
1243        Trigger mode hidden for output actions.
1244        """
1245        input_type_str = self._input_type_var.get()
1246        is_analog = input_type_str == InputType.ANALOG.value
1247        is_va = input_type_str == InputType.VIRTUAL_ANALOG.value
1248        is_bool_trigger = input_type_str == InputType.BOOLEAN_TRIGGER.value
1249        is_button = input_type_str == InputType.BUTTON.value
1250        # VA output goes through the analog shaping pipeline
1251        show_axis = is_analog or is_va
1252
1253        # Threshold: visible for BUTTON (disabled) and BOOLEAN_TRIGGER (enabled)
1254        if is_button or is_bool_trigger:
1255            self._threshold_label.grid()
1256            self._threshold_spin.grid()
1257            self._threshold_spin.config(
1258                state="normal" if is_bool_trigger else "disabled")
1259        else:
1260            self._threshold_label.grid_remove()
1261            self._threshold_spin.grid_remove()
1262
1263        for label, widget in self._axis_widgets:
1264            if show_axis:
1265                label.grid()
1266                widget.grid()
1267            else:
1268                label.grid_remove()
1269                widget.grid_remove()
1270
1271        # Neg slew frame: show/hide with other axis widgets
1272        for w in self._neg_slew_widgets:
1273            if show_axis:
1274                w.grid()
1275            else:
1276                w.grid_remove()
1277
1278        # VA-specific frame
1279        if is_va:
1280            self._va_outer_frame.grid()
1281        else:
1282            self._va_outer_frame.grid_remove()
1283
1284        # Refresh combo values to re-gate SPLINE based on current trigger
1285        if show_axis:
1286            self._refresh_spline_gate()
1287
1288        # Spline controls: visible only for ANALOG + SPLINE
1289        trigger_str = self._trigger_var.get()
1290        show_spline = (is_analog
1291                       and trigger_str == EventTriggerMode.SPLINE.value)
1292        for w in self._spline_widgets:
1293            if show_spline:
1294                w.grid()
1295            else:
1296                w.grid_remove()
1297
1298        # Segment controls: visible only for ANALOG + SEGMENTED
1299        show_segments = (is_analog
1300                         and trigger_str == EventTriggerMode.SEGMENTED.value)
1301        for w in self._segment_widgets:
1302            if show_segments:
1303                w.grid()
1304            else:
1305                w.grid_remove()
1306
1307        # Disable axis fields when trigger mode is RAW (bypasses all shaping)
1308        if show_axis:
1309            is_raw = trigger_str == EventTriggerMode.RAW.value
1310            raw_state = "disabled" if is_raw else "normal"
1311            self._deadband_spin.config(state=raw_state)
1312            self._inversion_check.config(state=raw_state)
1313            self._scale_spin.config(state=raw_state)
1314            self._slew_spin.config(state=raw_state)
1315            self._neg_slew_check.config(state=raw_state)
1316            if is_raw:
1317                self._neg_slew_spin.config(state="disabled")
1318            else:
1319                neg_state = ("normal" if self._neg_slew_enable_var.get()
1320                             else "disabled")
1321                self._neg_slew_spin.config(state=neg_state)
1322
1323    # ------------------------------------------------------------------
1324    # Detail Form Changes
1325    # ------------------------------------------------------------------
1326
1327    def _save_detail(self):
1328        """Save the detail form back to the action. Returns True if saved."""
1329        if self._updating_form or self._selected_name is None:
1330            return False
1331
1332        action = self._actions.get(self._selected_name)
1333        if not action:
1334            return False
1335
1336        try:
1337            action.description = self._desc_text.get(
1338                "1.0", "end-1c").strip()
1339            action.input_type = InputType(self._input_type_var.get())
1340            if action.input_type == InputType.OUTPUT:
1341                action.trigger_mode = EventTriggerMode.RAW
1342            else:
1343                trig_val = self._trigger_var.get()
1344                if not trig_val.endswith(_SPLINE_ADV_SUFFIX):
1345                    action.trigger_mode = EventTriggerMode(trig_val)
1346            if action.input_type == InputType.BOOLEAN_TRIGGER:
1347                action.threshold = float(
1348                    self._threshold_var.get() or 0.5)
1349            # RAW mode bypasses all shaping — reset to defaults so
1350            # the saved YAML doesn't contain misleading values.
1351            if action.trigger_mode == EventTriggerMode.RAW:
1352                action.deadband = 0.0
1353                action.inversion = False
1354                action.scale = 1.0
1355                action.slew_rate = 0.0
1356                action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1357            else:
1358                action.deadband = float(
1359                    self._deadband_var.get() or 0)
1360                action.inversion = self._inversion_var.get()
1361                action.scale = float(self._scale_var.get() or 1.0)
1362                action.slew_rate = float(
1363                    self._slew_var.get() or 0.0)
1364                if self._neg_slew_enable_var.get():
1365                    val = float(self._neg_slew_var.get() or 0.0)
1366                    action.extra[EXTRA_NEGATIVE_SLEW_RATE] = min(
1367                        val, 0.0)
1368                else:
1369                    action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1370            # Virtual Analog extra fields
1371            if action.input_type == InputType.VIRTUAL_ANALOG:
1372                action.extra[EXTRA_VA_BUTTON_MODE] = (
1373                    self._va_mode_var.get() or "held")
1374                action.extra[EXTRA_VA_RAMP_RATE] = float(
1375                    self._va_ramp_var.get() or 0.0)
1376                action.extra[EXTRA_VA_ACCELERATION] = float(
1377                    self._va_accel_var.get() or 0.0)
1378                action.extra[EXTRA_VA_TARGET_VALUE] = float(
1379                    self._va_target_var.get() or 1.0)
1380                action.extra[EXTRA_VA_REST_VALUE] = float(
1381                    self._va_rest_var.get() or 0.0)
1382                action.extra[EXTRA_VA_ZERO_VEL_ON_RELEASE] = bool(
1383                    self._va_zero_vel_var.get())
1384                if self._va_neg_ramp_enable_var.get():
1385                    action.extra[EXTRA_VA_NEGATIVE_RAMP_RATE] = float(
1386                        self._va_neg_ramp_var.get() or 0.0)
1387                else:
1388                    action.extra.pop(EXTRA_VA_NEGATIVE_RAMP_RATE, None)
1389                if self._va_neg_accel_enable_var.get():
1390                    action.extra[EXTRA_VA_NEGATIVE_ACCELERATION] = float(
1391                        self._va_neg_accel_var.get() or 0.0)
1392                else:
1393                    action.extra.pop(EXTRA_VA_NEGATIVE_ACCELERATION, None)
1394        except (ValueError, KeyError):
1395            return False
1396
1397        return True
1398
1399    def _on_desc_modified(self, event=None):
1400        """Handle description Text widget changes."""
1401        if not self._desc_text.edit_modified():
1402            return
1403        self._desc_text.edit_modified(False)
1404        if self._updating_form:
1405            return
1406        self._on_field_changed()
1407
1408    def _on_field_changed(self, *args):
1409        """Handle changes to detail fields (not name or group)."""
1410        if self._updating_form:
1411            return
1412        if self._on_before_change:
1413            self._on_before_change(500)
1414        self._update_type_visibility()
1415        if self._save_detail() and self._on_actions_changed:
1416            # Mark as user-customized (unless this is an auto-set
1417            # from a type switch — that resets the flag after)
1418            if not self._type_switch_active and self._selected_name:
1419                action = self._actions.get(self._selected_name)
1420                if action:
1421                    action._has_custom = True
1422            self._on_actions_changed()
1423
1424    def _on_neg_slew_toggled(self, *args):
1425        """Enable/disable the negative slew rate spinbox."""
1426        enabled = self._neg_slew_enable_var.get()
1427        self._neg_slew_spin.config(state="normal" if enabled else "disabled")
1428        if not self._updating_form:
1429            self._on_field_changed()
1430
1431    def _on_va_neg_ramp_toggled(self, *args):
1432        """Enable/disable the VA negative ramp rate spinbox."""
1433        enabled = self._va_neg_ramp_enable_var.get()
1434        self._va_neg_ramp_spin.config(
1435            state="normal" if enabled else "disabled")
1436        if not self._updating_form:
1437            self._on_field_changed()
1438
1439    def _on_va_neg_accel_toggled(self, *args):
1440        """Enable/disable the VA negative acceleration spinbox."""
1441        enabled = self._va_neg_accel_enable_var.get()
1442        self._va_neg_accel_spin.config(
1443            state="normal" if enabled else "disabled")
1444        if not self._updating_form:
1445            self._on_field_changed()
1446
1447    def _on_input_type_changed(self, *args):
1448        """Handle input type dropdown change."""
1449        if not self._updating_form:
1450            try:
1451                input_type = InputType(self._input_type_var.get())
1452            except ValueError:
1453                input_type = InputType.BUTTON
1454
1455            # Warn on any input type change if user-customized settings exist
1456            if self._selected_name:
1457                action = self._actions.get(self._selected_name)
1458                if (action and input_type != action.input_type
1459                        and getattr(action, '_has_custom', False)):
1460                    if not messagebox.askyesno(
1461                        "Change Input Type",
1462                        "Changing input type may reset or\n"
1463                        "invalidate current settings (deadband,\n"
1464                        "scale, curves, bindings). Continue?",
1465                    ):
1466                        self._updating_form = True
1467                        try:
1468                            self._input_type_var.set(
1469                                action.input_type.value)
1470                        finally:
1471                            self._updating_form = False
1472                        return
1473
1474            # Suppress intermediate traces from deadband/trigger var changes
1475            # so the final _on_field_changed captures a single pre-mutation snapshot
1476            self._updating_form = True
1477            try:
1478                if input_type == InputType.ANALOG:
1479                    # Default deadband to 5% when switching to analog
1480                    try:
1481                        current_db = float(self._deadband_var.get() or 0)
1482                    except ValueError:
1483                        current_db = 0.0
1484                    if current_db == 0.0:
1485                        self._deadband_var.set("0.05")
1486                elif input_type == InputType.VIRTUAL_ANALOG:
1487                    # VA defaults: no deadband (no physical axis)
1488                    self._deadband_var.set("0.0")
1489                    self._va_ramp_var.set("1.0")
1490                    self._va_accel_var.set("0.0")
1491                    self._va_target_var.set("1.0")
1492                    self._va_rest_var.set("0.0")
1493                    self._va_zero_vel_var.set(False)
1494                    self._va_neg_ramp_enable_var.set(False)
1495                    self._va_neg_ramp_var.set("0.0")
1496                    self._va_neg_accel_enable_var.set(False)
1497                    self._va_neg_accel_var.set("0.0")
1498                else:
1499                    # Reset analog-specific fields to defaults
1500                    self._deadband_var.set("0.0")
1501                    self._inversion_var.set(False)
1502                    self._scale_var.set("1.0")
1503                    self._slew_var.set("0.0")
1504                    self._neg_slew_enable_var.set(False)
1505                    self._neg_slew_var.set("0.0")
1506                    action = self._actions.get(self._selected_name)
1507                    if action:
1508                        action.extra.pop(EXTRA_SPLINE_POINTS, None)
1509                        action.extra.pop(EXTRA_SEGMENT_POINTS, None)
1510                        action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1511                # Clean up VA extra keys when switching away from VA
1512                if input_type != InputType.VIRTUAL_ANALOG:
1513                    action = self._actions.get(self._selected_name)
1514                    if action:
1515                        for key in (EXTRA_VA_BUTTON_MODE,
1516                                    EXTRA_VA_RAMP_RATE,
1517                                    EXTRA_VA_ACCELERATION,
1518                                    EXTRA_VA_NEGATIVE_RAMP_RATE,
1519                                    EXTRA_VA_NEGATIVE_ACCELERATION,
1520                                    EXTRA_VA_ZERO_VEL_ON_RELEASE,
1521                                    EXTRA_VA_TARGET_VALUE,
1522                                    EXTRA_VA_REST_VALUE):
1523                            action.extra.pop(key, None)
1524                # Update trigger mode options and default
1525                self._update_trigger_mode_options(input_type)
1526            finally:
1527                self._updating_form = False
1528
1529            # Save fields and update visibility, but suppress the
1530            # _has_custom flag — type switches reset it to False
1531            self._type_switch_active = True
1532            self._update_type_visibility()
1533            self._on_field_changed()
1534            self._type_switch_active = False
1535            action = self._actions.get(self._selected_name)
1536            if action:
1537                action._has_custom = False
1538
1539    def _on_edit_spline(self):
1540        """Open the spline editor dialog for the selected action."""
1541        if self._selected_name is None:
1542            return
1543        action = self._actions.get(self._selected_name)
1544        if not action:
1545            return
1546
1547        from host.controller_config.spline_editor import (
1548            SplineEditorDialog, default_points,
1549        )
1550
1551        points = action.extra.get(EXTRA_SPLINE_POINTS)
1552        if not points:
1553            points = default_points()
1554
1555        # Collect spline curves from other actions for "Copy from..."
1556        other_curves = {}
1557        for qname, act in self._actions.items():
1558            if qname != self._selected_name:
1559                pts = act.extra.get(EXTRA_SPLINE_POINTS)
1560                if pts:
1561                    other_curves[qname] = pts
1562
1563        dialog = SplineEditorDialog(self.winfo_toplevel(), points,
1564                                    other_curves,
1565                                    scale=action.scale,
1566                                    inversion=action.inversion)
1567        result = dialog.get_result()
1568
1569        if result is not None:
1570            if self._on_before_change:
1571                self._on_before_change(0)
1572            action.extra[EXTRA_SPLINE_POINTS] = result
1573            if self._on_actions_changed:
1574                self._on_actions_changed()
1575
1576    def _on_edit_segments(self):
1577        """Open the segment editor dialog for the selected action."""
1578        if self._selected_name is None:
1579            return
1580        action = self._actions.get(self._selected_name)
1581        if not action:
1582            return
1583
1584        from host.controller_config.segment_editor import (
1585            SegmentEditorDialog, default_segment_points,
1586        )
1587
1588        points = action.extra.get(EXTRA_SEGMENT_POINTS)
1589        if not points:
1590            points = default_segment_points()
1591
1592        # Collect segment curves from other actions for "Copy from..."
1593        other_curves = {}
1594        for qname, act in self._actions.items():
1595            if qname != self._selected_name:
1596                pts = act.extra.get(EXTRA_SEGMENT_POINTS)
1597                if pts:
1598                    other_curves[qname] = pts
1599
1600        flags = self._get_advanced_flags()
1601        dialog = SegmentEditorDialog(self.winfo_toplevel(), points,
1602                                     other_curves,
1603                                     scale=action.scale,
1604                                     inversion=action.inversion,
1605                                     allow_nonmono=flags["nonmono"])
1606        result = dialog.get_result()
1607
1608        if result is not None:
1609            if self._on_before_change:
1610                self._on_before_change(0)
1611            action.extra[EXTRA_SEGMENT_POINTS] = result
1612            if self._on_actions_changed:
1613                self._on_actions_changed()
1614
1615    def _set_field_error(self, widget, has_error: bool):
1616        """Apply or clear error styling on a ttk Entry or Combobox."""
1617        base = widget.winfo_class()  # "TEntry" or "TCombobox"
1618        widget.configure(style=f"Error.{base}" if has_error else f"{base}")
1619
1620    def _flash_field_warning(self, widget, err: str):
1621        """Flash error styling and show a warning for an invalid field."""
1622        if getattr(self, '_showing_warning', False):
1623            return
1624        self._showing_warning = True
1625        self._set_field_error(widget, True)
1626        messagebox.showwarning("Invalid Value", err)
1627        self._set_field_error(widget, False)
1628        self._showing_warning = False
1629
1630    def _on_name_changed(self, *args):
1631        """Trace callback — deferred to commit on focus-out / Enter."""
1632        # Only update the tree label in real-time; actual rename is deferred
1633        pass
1634
1635    def _commit_name(self, *args):
1636        """Commit name change on focus-out or Enter key."""
1637        if self._updating_form or self._selected_name is None:
1638            return
1639
1640        new_name = self._name_var.get().strip()
1641        old_qname = self._selected_name
1642        action = self._actions.get(old_qname)
1643        if not action:
1644            return
1645
1646        # No change — just clear any error styling
1647        if not new_name or new_name == action.name:
1648            self._set_field_error(self._name_entry, False)
1649            return
1650
1651        err = validate_action_name(new_name)
1652        if not err:
1653            new_qname = f"{action.group}.{new_name}"
1654            err = validate_action_rename(
1655                old_qname, new_qname, self._actions)
1656
1657        if err:
1658            self._flash_field_warning(self._name_entry, err)
1659            # Revert to the current valid name
1660            self._updating_form = True
1661            self._name_var.set(action.name)
1662            self._updating_form = False
1663            return
1664
1665        self._set_field_error(self._name_entry, False)
1666        if self._on_before_change:
1667            self._on_before_change(500)
1668        del self._actions[old_qname]
1669        action.name = new_name
1670        self._actions[new_qname] = action
1671        self._selected_name = new_qname
1672
1673        # Update binding references from old to new qualified name
1674        if self._on_action_renamed and old_qname != new_qname:
1675            self._on_action_renamed(old_qname, new_qname)
1676
1677        self._refresh_tree()
1678        self._reselect(new_qname)
1679
1680        if self._on_actions_changed:
1681            self._on_actions_changed()
1682
1683    def rename_action(self, old_qname: str, new_qname: str):
1684        """Re-key an action in the dict and refresh the tree.
1685
1686        Called by the App when the Action Editor tab changes an action's
1687        name or group, so the sidebar tree stays in sync.
1688        Returns True if the rename succeeded, False if rejected.
1689        """
1690        action = self._actions.get(old_qname)
1691        if not action or old_qname == new_qname:
1692            return False
1693        err = validate_action_rename(old_qname, new_qname, self._actions)
1694        if err:
1695            messagebox.showwarning("Invalid Name", err)
1696            return False
1697
1698        del self._actions[old_qname]
1699        self._actions[new_qname] = action
1700        self._selected_name = new_qname
1701
1702        if self._on_action_renamed:
1703            self._on_action_renamed(old_qname, new_qname)
1704
1705        self._refresh_tree()
1706        self._reselect(new_qname)
1707        return True
1708
1709    def _on_group_changed(self, *args):
1710        """Trace callback — deferred to commit on focus-out / Enter."""
1711        pass
1712
1713    def _commit_group(self, *args):
1714        """Commit group change on focus-out or Enter key."""
1715        if self._updating_form or self._selected_name is None:
1716            return
1717
1718        new_group = self._group_var.get().strip()
1719        action = self._actions.get(self._selected_name)
1720        if not action:
1721            return
1722
1723        if not new_group:
1724            self._flash_field_warning(
1725                self._group_combo, "Group cannot be empty.")
1726            self._updating_form = True
1727            self._group_var.set(action.group)
1728            self._updating_form = False
1729            return
1730
1731        if new_group == action.group:
1732            self._set_field_error(self._group_combo, False)
1733            return
1734
1735        self._set_field_error(self._group_combo, False)
1736        self._move_action_to_group(self._selected_name, new_group)
1737
1738    def _move_action_to_group(self, qname: str, new_group: str):
1739        """Move an action to a different group, rejecting on collision."""
1740        action = self._actions.get(qname)
1741        if not action or new_group == action.group:
1742            return
1743
1744        new_qname = f"{new_group}.{action.name}"
1745        if new_qname in self._actions:
1746            self._flash_field_warning(
1747                self._group_combo,
1748                f"An action named '{new_qname}' already exists.\n"
1749                "Rename the action first, or choose a different group.")
1750            # Revert the group field
1751            self._updating_form = True
1752            self._group_var.set(action.group)
1753            self._updating_form = False
1754            return
1755
1756        if self._on_before_change:
1757            self._on_before_change(0)
1758
1759        old_qname = qname
1760        old_group = action.group
1761        del self._actions[qname]
1762
1763        action.group = new_group
1764        self._actions[new_qname] = action
1765        self._selected_name = new_qname
1766
1767        # Preserve old group as empty if it has no remaining actions
1768        if not any(a.group == old_group for a in self._actions.values()):
1769            self._empty_groups.add(old_group)
1770
1771        # Remove new group from empty tracking since it now has actions
1772        self._empty_groups.discard(new_group)
1773
1774        # Update binding references from old to new qualified name
1775        if self._on_action_renamed and old_qname != new_qname:
1776            self._on_action_renamed(old_qname, new_qname)
1777
1778        self._refresh_tree()
1779        self._reselect(new_qname)
1780        self._load_detail(new_qname)
1781
1782        if self._on_actions_changed:
1783            self._on_actions_changed()
1784
1785    # ------------------------------------------------------------------
1786    # Action CRUD
1787    # ------------------------------------------------------------------
1788
1789    def _add_action(self):
1790        """Add a new action in the currently selected group, or 'general'."""
1791        if self._on_before_change:
1792            self._on_before_change(0)
1793        group = self._get_selected_group()
1794
1795        base = "new_action"
1796        name = base
1797        i = 1
1798        while f"{group}.{name}" in self._actions:
1799            name = f"{base}_{i}"
1800            i += 1
1801
1802        qname = f"{group}.{name}"
1803        action = ActionDefinition(name=name, group=group)
1804        action._has_custom = False
1805        self._actions[qname] = action
1806        self._empty_groups.discard(group)
1807        self._refresh_tree()
1808        self._reselect(qname)
1809
1810        self._selected_name = qname
1811        self._load_detail(qname)
1812        self._set_detail_enabled(True)
1813
1814        self._name_entry.focus_set()
1815        self._name_entry.select_range(0, tk.END)
1816
1817        if self._on_actions_changed:
1818            self._on_actions_changed()
1819
1820    def _remove_action(self):
1821        """Remove the selected action."""
1822        if self._selected_name is None:
1823            return
1824
1825        name = self._selected_name
1826        action = self._actions.get(name)
1827        display = action.qualified_name if action else name
1828        if not messagebox.askyesno("Remove Action",
1829                                   f"Remove action '{display}'?"):
1830            return
1831
1832        if self._on_before_change:
1833            self._on_before_change(0)
1834        del self._actions[name]
1835        self._selected_name = None
1836        self._refresh_tree()
1837        self._set_detail_enabled(False)
1838
1839        if self._on_actions_changed:
1840            self._on_actions_changed()
1841
1842    def _duplicate_action(self):
1843        """Duplicate the selected action with a new name."""
1844        if self._selected_name is None:
1845            return
1846
1847        if self._on_before_change:
1848            self._on_before_change(0)
1849        src = self._actions[self._selected_name]
1850        base = f"{src.name}_copy"
1851        name = base
1852        i = 1
1853        while f"{src.group}.{name}" in self._actions:
1854            name = f"{base}_{i}"
1855            i += 1
1856
1857        new_action = ActionDefinition(
1858            name=name,
1859            description=src.description,
1860            group=src.group,
1861            input_type=src.input_type,
1862            trigger_mode=src.trigger_mode,
1863            deadband=src.deadband,
1864            inversion=src.inversion,
1865            scale=src.scale,
1866            extra=dict(src.extra),
1867        )
1868        qname = new_action.qualified_name
1869        self._actions[qname] = new_action
1870        self._refresh_tree()
1871        self._reselect(qname)
1872
1873        self._selected_name = qname
1874        self._load_detail(qname)
1875        self._set_detail_enabled(True)
1876
1877        if self._on_actions_changed:
1878            self._on_actions_changed()
1879
1880    # ------------------------------------------------------------------
1881    # Group Management
1882    # ------------------------------------------------------------------
1883
1884    def _add_group(self):
1885        """Prompt for a new group name and create an empty group node."""
1886        name = simpledialog.askstring("New Group", "Group name:", parent=self)
1887        if not name or not name.strip():
1888            return
1889        name = name.strip().lower().replace(" ", "_")
1890
1891        # Check if group already exists
1892        groups = self._collect_groups()
1893        if name in groups:
1894            messagebox.showinfo("Group Exists",
1895                                f"Group '{name}' already exists.")
1896            return
1897
1898        self._empty_groups.add(name)
1899        self._refresh_tree()
1900
1901    def _remove_group(self):
1902        """Remove the selected group and all its actions."""
1903        group = self._get_selected_group_name()
1904        if not group:
1905            messagebox.showinfo("No Group Selected",
1906                                "Select a group to remove.")
1907            return
1908
1909        group_actions = [qn for qn, a in self._actions.items()
1910                         if a.group == group]
1911
1912        if group_actions:
1913            if not messagebox.askyesno(
1914                "Remove Group",
1915                f"Remove group '{group}' and its "
1916                f"{len(group_actions)} action(s)?",
1917            ):
1918                return
1919            if self._on_before_change:
1920                self._on_before_change(0)
1921            for qn in group_actions:
1922                del self._actions[qn]
1923        else:
1924            if self._on_before_change:
1925                self._on_before_change(0)
1926            self._empty_groups.discard(group)
1927
1928        self._selected_name = None
1929        self._refresh_tree()
1930        self._set_detail_enabled(False)
1931
1932        if self._on_actions_changed:
1933            self._on_actions_changed()
1934
1935    def _rename_group(self):
1936        """Rename the selected group and update all its actions."""
1937        old_name = self._get_selected_group_name()
1938        if not old_name:
1939            messagebox.showinfo("No Group Selected",
1940                                "Select a group to rename.")
1941            return
1942
1943        new_name = simpledialog.askstring(
1944            "Rename Group", "New name:", initialvalue=old_name, parent=self)
1945        if not new_name or not new_name.strip():
1946            return
1947        new_name = new_name.strip().lower().replace(" ", "_")
1948
1949        if new_name == old_name:
1950            return
1951
1952        # Validate: no dots allowed in group names
1953        if "." in new_name:
1954            messagebox.showerror("Invalid Name",
1955                                 "Group names cannot contain dots.")
1956            return
1957
1958        # Check for collision with existing groups
1959        groups = self._collect_groups()
1960        if new_name in groups:
1961            messagebox.showerror("Group Exists",
1962                                 f"Group '{new_name}' already exists.")
1963            return
1964
1965        if self._on_before_change:
1966            self._on_before_change(0)
1967
1968        # Collect actions in this group
1969        group_actions = [(qn, a) for qn, a in list(self._actions.items())
1970                         if a.group == old_name]
1971
1972        # Re-key each action with the new group
1973        for old_qname, action in group_actions:
1974            del self._actions[old_qname]
1975            action.group = new_name
1976            new_qname = action.qualified_name
1977            self._actions[new_qname] = action
1978
1979            if self._on_action_renamed:
1980                self._on_action_renamed(old_qname, new_qname)
1981
1982        # Update empty groups tracking
1983        if old_name in self._empty_groups:
1984            self._empty_groups.discard(old_name)
1985            self._empty_groups.add(new_name)
1986
1987        # Update selection to the renamed group
1988        if self._selected_name:
1989            # If the selected action was in this group, update reference
1990            for old_qname, action in group_actions:
1991                if self._selected_name == old_qname:
1992                    self._selected_name = action.qualified_name
1993                    break
1994
1995        self._refresh_tree()
1996
1997        if self._on_actions_changed:
1998            self._on_actions_changed()
1999
2000    # ------------------------------------------------------------------
2001    # Context Menu (Right-Click)
2002    # ------------------------------------------------------------------
2003
2004    def _on_assign_button(self):
2005        """Open the assign context menu from the Assign button."""
2006        if not self._selected_name:
2007            return
2008        if self._selected_name not in self._actions:
2009            return
2010        # Position menu at the button
2011        btn = self._assign_btn
2012        x = btn.winfo_rootx()
2013        y = btn.winfo_rooty() + btn.winfo_height()
2014
2015        class _FakeEvent:
2016            pass
2017
2018        evt = _FakeEvent()
2019        evt.x_root = x
2020        evt.y_root = y
2021        self._show_action_context_menu(evt, self._selected_name)
2022
2023    def _on_right_click(self, event):
2024        """Show context menu on right-click."""
2025        item = self._tree.identify_row(event.y)
2026        if not item:
2027            return
2028        if item.startswith(self._GROUP_PREFIX):
2029            self._tree.selection_set(item)
2030            self._context_menu.post(event.x_root, event.y_root)
2031        elif item in self._actions:
2032            self._tree.selection_set(item)
2033            self._show_action_context_menu(event, item)
2034
2035    def _show_action_context_menu(self, event, qname: str):
2036        """Build and show a context menu for an action item.
2037
2038        Shows each controller as a submenu with its compatible inputs.
2039        Bound inputs have a checkmark and clicking unassigns them.
2040        Unbound inputs are plain and clicking assigns them.
2041        """
2042        if not (self._get_all_controllers and self._get_compatible_inputs):
2043            return
2044
2045        controllers = self._get_all_controllers()
2046        compatible = self._get_compatible_inputs(qname)
2047
2048        menu = tk.Menu(self, tearoff=0)
2049        has_any_binding = False
2050
2051        for port, ctrl_name in controllers:
2052            sub = tk.Menu(menu, tearoff=0)
2053            has_bound = False
2054            has_unbound = False
2055            bound_items = []
2056            unbound_items = []
2057
2058            for input_name, display_name in compatible:
2059                # Check if this action is bound to this input on this port
2060                is_bound = self._is_action_bound(
2061                    qname, port, input_name)
2062                if is_bound:
2063                    has_bound = True
2064                    has_any_binding = True
2065                    bound_items.append((input_name, display_name))
2066                else:
2067                    has_unbound = True
2068                    unbound_items.append((input_name, display_name))
2069
2070            # Add bound inputs first (with checkmark)
2071            for input_name, display_name in bound_items:
2072                sub.add_command(
2073                    label=f"\u2713 {display_name}",
2074                    command=lambda q=qname, p=port, n=input_name:
2075                        self._on_unassign_action(q, p, n)
2076                        if self._on_unassign_action else None,
2077                )
2078
2079            # Separator between bound and unbound
2080            if has_bound and has_unbound:
2081                sub.add_separator()
2082
2083            # Add unbound compatible inputs
2084            for input_name, display_name in unbound_items:
2085                sub.add_command(
2086                    label=display_name,
2087                    command=lambda q=qname, p=port, n=input_name:
2088                        self._on_assign_action(q, p, n)
2089                        if self._on_assign_action else None,
2090                )
2091
2092            label = f"{ctrl_name} (Port {port})"
2093            menu.add_cascade(label=label, menu=sub)
2094
2095        menu.add_separator()
2096        menu.add_command(
2097            label="Remove from All Inputs",
2098            command=lambda q=qname:
2099                self._on_unassign_all(q)
2100                if self._on_unassign_all else None,
2101            state=tk.NORMAL if has_any_binding else tk.DISABLED,
2102        )
2103
2104        menu.tk_popup(event.x_root, event.y_root)
2105
2106    def _is_action_bound(self, qname: str, port: int,
2107                         input_name: str) -> bool:
2108        """Check if an action is bound to a specific input on a port."""
2109        if self._is_action_bound_cb:
2110            return self._is_action_bound_cb(qname, port, input_name)
2111        return False
2112
2113    def _on_context_export_group(self):
2114        """Handle 'Export Group...' from context menu."""
2115        group = self._get_selected_group_name()
2116        if group and self._on_export_group:
2117            self._on_export_group(group)
2118
2119    # ------------------------------------------------------------------
2120    # Drag-from-Tree (cross-widget drag-and-drop)
2121    # ------------------------------------------------------------------
2122
2123    def _on_tree_scroll(self, event):
2124        """Cancel any drag (pending or active) when the user scrolls.
2125
2126        Scrolling shifts items under the cursor, so a release after
2127        scrolling could target the wrong group.
2128        """
2129        if self._drag_item:
2130            if self._drag_started and self._on_drag_end:
2131                self._on_drag_end()
2132            self._drag_item = None
2133            self._drag_started = False
2134            self._drag_target_group = None
2135            self._clear_drag_highlight()
2136
2137    def _on_tree_press(self, event):
2138        """Record potential drag start position."""
2139        item = self._tree.identify_row(event.y)
2140        if item and not item.startswith(self._GROUP_PREFIX) and item in self._actions:
2141            self._drag_item = item
2142            self._drag_start_pos = (event.x_root, event.y_root)
2143            self._drag_started = False
2144        else:
2145            self._drag_item = None
2146
2147    def _on_tree_drag(self, event):
2148        """Start drag after movement exceeds threshold."""
2149        if not self._drag_item:
2150            return
2151
2152        if not self._drag_started:
2153            dx = event.x_root - self._drag_start_pos[0]
2154            dy = event.y_root - self._drag_start_pos[1]
2155            if (dx * dx + dy * dy) < self._DRAG_THRESHOLD ** 2:
2156                return
2157            self._drag_started = True
2158            if self._on_drag_start:
2159                self._on_drag_start(self._drag_item)
2160
2161        # Track intra-tree group target for visual feedback
2162        # Only consider intra-tree drops when mouse is inside the tree widget;
2163        # implicit grab delivers events even when the mouse is over other widgets
2164        if self._is_over_tree(event):
2165            item = self._tree.identify_row(event.y)
2166            target_group = self._resolve_group_for_item(item)
2167        else:
2168            target_group = None
2169
2170        action = self._actions.get(self._drag_item)
2171        source_group = action.group if action else None
2172
2173        if target_group and target_group != source_group:
2174            group_iid = f"{self._GROUP_PREFIX}{target_group}"
2175            self._set_drag_highlight(group_iid)
2176        else:
2177            self._clear_drag_highlight()
2178        self._drag_target_group = target_group
2179
2180    def _on_tree_release(self, event):
2181        """Handle release — move action to target group or let app handle."""
2182        drag_item = self._drag_item
2183        was_dragging = self._drag_started
2184
2185        # Reset all local drag state
2186        self._drag_item = None
2187        self._drag_started = False
2188        self._drag_target_group = None
2189        self._clear_drag_highlight()
2190
2191        if not was_dragging or not drag_item:
2192            return
2193
2194        # Check if released over a group in the tree (not outside the widget)
2195        if not self._is_over_tree(event):
2196            return
2197
2198        item = self._tree.identify_row(event.y)
2199        target_group = self._resolve_group_for_item(item)
2200
2201        action = self._actions.get(drag_item)
2202        if action and target_group and target_group != action.group:
2203            # Cancel cross-widget drag before moving
2204            if self._on_drag_end:
2205                self._on_drag_end()
2206            self._move_action_to_group(drag_item, target_group)
2207
2208    def _set_drag_highlight(self, group_iid: str):
2209        """Highlight a group node as a drop target."""
2210        if group_iid == self._drag_highlight_iid:
2211            return
2212        self._clear_drag_highlight()
2213        if group_iid and self._tree.exists(group_iid):
2214            self._tree.tag_configure(
2215                "drop_target",
2216                background="#cce5ff",
2217                font=("TkDefaultFont", 9, "bold"))
2218            self._tree.item(group_iid, tags=("group", "drop_target"))
2219            self._drag_highlight_iid = group_iid
2220
2221    def _clear_drag_highlight(self):
2222        """Remove drop target highlight."""
2223        if self._drag_highlight_iid and self._tree.exists(
2224                self._drag_highlight_iid):
2225            self._tree.item(self._drag_highlight_iid, tags=("group",))
2226        self._drag_highlight_iid = None
2227
2228    # ------------------------------------------------------------------
2229    # Helpers
2230
2231    def _is_over_tree(self, event) -> bool:
2232        """Check if event coordinates are within the tree widget bounds."""
2233        return (0 <= event.x <= self._tree.winfo_width()
2234                and 0 <= event.y <= self._tree.winfo_height())
2235
2236    def _resolve_group_for_item(self, item: str | None) -> str | None:
2237        """Return the group name for a tree item, or None."""
2238        if not item:
2239            return None
2240        if item.startswith(self._GROUP_PREFIX):
2241            return item[len(self._GROUP_PREFIX):]
2242        if item.startswith(self._EMPTY_PREFIX):
2243            return item[len(self._EMPTY_PREFIX):]
2244        if item in self._actions:
2245            return self._actions[item].group
2246        return None
2247    # ------------------------------------------------------------------
2248
2249    def _get_selected_group(self) -> str:
2250        """Return the group for the current selection, defaulting to 'general'."""
2251        sel = self._tree.selection()
2252        if sel:
2253            item_id = sel[0]
2254            if item_id.startswith(self._GROUP_PREFIX):
2255                return item_id[len(self._GROUP_PREFIX):]
2256            if item_id in self._actions:
2257                return self._actions[item_id].group
2258        return DEFAULT_GROUP
2259
2260    def _get_selected_group_name(self) -> str | None:
2261        """Return the group name if a group node is selected, else None."""
2262        sel = self._tree.selection()
2263        if sel:
2264            item_id = sel[0]
2265            if item_id.startswith(self._GROUP_PREFIX):
2266                return item_id[len(self._GROUP_PREFIX):]
2267            # Also allow removing a group when an action in it is selected
2268            if item_id in self._actions:
2269                return self._actions[item_id].group
2270        return None
2271
2272    def _get_tooltip_text(self, item_id: str) -> str | None:
2273        """Return tooltip text for a tree item, or None."""
2274        if item_id.startswith(self._GROUP_PREFIX):
2275            group = item_id[len(self._GROUP_PREFIX):]
2276            group_actions = [a for a in self._actions.values()
2277                             if a.group == group]
2278            count = len(group_actions)
2279            lines = [f"{group} ({count} action{'s' if count != 1 else ''})"]
2280
2281            if self._get_binding_info and group_actions:
2282                unassigned = 0
2283                multi_bound = 0
2284                for a in group_actions:
2285                    bindings = self._get_binding_info(a.qualified_name)
2286                    if not bindings:
2287                        unassigned += 1
2288                    elif len(bindings) > 1:
2289                        multi_bound += 1
2290                if unassigned:
2291                    lines.append(
2292                        f"{unassigned} unassigned")
2293                if multi_bound:
2294                    lines.append(
2295                        f"{multi_bound} bound to multiple inputs")
2296
2297            return "\n".join(lines)
2298
2299        action = self._actions.get(item_id)
2300        if not action:
2301            return None
2302
2303        lines = [action.qualified_name]
2304        if action.description:
2305            lines.append(action.description)
2306
2307        # Show binding assignments
2308        if self._get_binding_info:
2309            bindings = self._get_binding_info(item_id)
2310            if bindings:
2311                lines.append("")
2312                lines.append("Assigned to:")
2313                for binding in bindings:
2314                    ctrl_name, input_display = binding[0], binding[1]
2315                    lines.append(f"  {ctrl_name} > {input_display}")
2316                if len(bindings) > 1:
2317                    lines.append("")
2318                    lines.append("[Yellow: bound to multiple inputs]")
2319            else:
2320                lines.append("")
2321                lines.append("Not assigned to any input")
2322                lines.append("[Red: unassigned]")
2323
2324        return "\n".join(lines)
2325
2326    def _reselect(self, qname: str):
2327        """Select and scroll to an item in the tree."""
2328        if self._tree.exists(qname):
2329            self._tree.selection_set(qname)
2330            self._tree.see(qname)
class ActionPanel(tkinter.Frame):
 162class ActionPanel(tk.Frame):
 163    """Panel for managing grouped action definitions."""
 164
 165    # Treeview item-id prefix for group nodes
 166    _GROUP_PREFIX = "group::"
 167
 168    # Drag threshold in pixels before drag-and-drop starts
 169    _DRAG_THRESHOLD = 8
 170
 171    def __init__(self, parent, on_actions_changed=None, on_export_group=None,
 172                 on_drag_start=None, on_drag_end=None,
 173                 on_before_change=None, get_binding_info=None,
 174                 on_assign_action=None, on_unassign_action=None,
 175                 on_unassign_all=None, get_all_controllers=None,
 176                 get_compatible_inputs=None, is_action_bound=None,
 177                 on_action_renamed=None,
 178                 on_selection_changed=None,
 179                 get_advanced_flags=None,
 180                 icon_loader=None):
 181        """
 182        Args:
 183            parent: tkinter parent widget
 184            on_actions_changed: callback() when any action is added/removed/modified
 185            on_export_group: callback(group_name) when user requests group export
 186            on_drag_start: callback(qname) when an action drag begins
 187            on_drag_end: callback() when a drag ends (release)
 188            on_before_change: callback(coalesce_ms) called BEFORE any mutation,
 189                giving the app a chance to snapshot state for undo
 190            get_binding_info: callback(qname) -> list[(ctrl_name, input_display)]
 191                returns where an action is bound, or empty list if unbound
 192            on_assign_action: callback(qname, port, input_name) to bind action
 193            on_unassign_action: callback(qname, port, input_name) to unbind action
 194            on_unassign_all: callback(qname) to remove action from all inputs
 195            get_all_controllers: callback() -> list[(port, ctrl_name)]
 196            get_compatible_inputs: callback(qname) ->
 197                list[(input_name, display_name)] of compatible inputs
 198            is_action_bound: callback(qname, port, input_name) -> bool
 199            on_action_renamed: callback(old_qname, new_qname) when an action's
 200                qualified name changes (group or name change) so bindings can
 201                be updated
 202            on_selection_changed: callback(qname | None) when tree selection
 203                changes, allowing external listeners to sync
 204        """
 205        super().__init__(parent, padx=5, pady=5)
 206        self._on_actions_changed = on_actions_changed
 207        self._on_export_group = on_export_group
 208        self._on_before_change = on_before_change
 209        self._on_drag_start = on_drag_start
 210        self._on_drag_end = on_drag_end
 211        self._get_binding_info = get_binding_info
 212        self._on_assign_action = on_assign_action
 213        self._on_unassign_action = on_unassign_action
 214        self._on_unassign_all = on_unassign_all
 215        self._get_all_controllers = get_all_controllers
 216        self._get_compatible_inputs = get_compatible_inputs
 217        self._is_action_bound_cb = is_action_bound
 218        self._on_action_renamed = on_action_renamed
 219        self._on_selection_changed = on_selection_changed
 220        self._get_advanced_flags = get_advanced_flags or (
 221            lambda: {"splines": True, "nonmono": True})
 222        self._icon_loader = icon_loader
 223        self._tree_icons: list = []  # Prevent GC of PhotoImage refs
 224        self._details_editable = True
 225        self._actions: dict[str, ActionDefinition] = {}
 226        self._empty_groups: set[str] = set()
 227        self._selected_name: str | None = None
 228        self._updating_form = False  # Guard against feedback loops
 229        self._type_switch_active = False  # True during type-change auto-sets
 230
 231        # Drag-from-tree state
 232        self._drag_item: str | None = None
 233        self._drag_start_pos: tuple[int, int] = (0, 0)
 234        self._drag_started: bool = False
 235        self._drag_target_group: str | None = None
 236        self._drag_highlight_iid: str | None = None
 237
 238        self._build_ui()
 239
 240    # ------------------------------------------------------------------
 241    # UI Construction
 242    # ------------------------------------------------------------------
 243
 244    def _build_ui(self):
 245        # Error styling for invalid field values
 246        style = ttk.Style(self)
 247        style.configure("Error.TEntry", foreground="red")
 248        style.configure("Error.TCombobox", foreground="red")
 249
 250        # --- Action Tree ---
 251        list_frame = ttk.LabelFrame(self, text="Actions", padding=5)
 252        list_frame.pack(fill=tk.BOTH, expand=True)
 253
 254        # Filter entry
 255        filter_frame = tk.Frame(list_frame)
 256        filter_frame.pack(fill=tk.X, pady=(0, 3))
 257        self._filter_var = tk.StringVar()
 258        self._filter_entry = ttk.Entry(
 259            filter_frame, textvariable=self._filter_var, width=20)
 260        self._filter_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
 261        self._filter_var.trace_add("write", self._on_filter_changed)
 262        self._filter_entry.bind(
 263            "<Escape>", lambda e: self._clear_filter())
 264        # Placeholder text
 265        self._filter_placeholder = True
 266        self._filter_entry.insert(0, "Filter actions...")
 267        self._filter_entry.config(foreground="grey")
 268        self._filter_entry.bind("<FocusIn>", self._on_filter_focus_in)
 269        self._filter_entry.bind("<FocusOut>", self._on_filter_focus_out)
 270
 271        # Binding status filter toggles
 272        self._filter_unassigned_var = tk.BooleanVar()
 273        self._filter_multi_var = tk.BooleanVar()
 274        self._filter_unassigned_cb = ttk.Checkbutton(
 275            filter_frame, text="Unassigned",
 276            variable=self._filter_unassigned_var,
 277            command=self._on_status_filter_changed,
 278        )
 279        self._filter_unassigned_cb.pack(side=tk.LEFT, padx=(4, 0))
 280        self._filter_multi_cb = ttk.Checkbutton(
 281            filter_frame, text="Multi",
 282            variable=self._filter_multi_var,
 283            command=self._on_status_filter_changed,
 284        )
 285        self._filter_multi_cb.pack(side=tk.LEFT, padx=(2, 0))
 286
 287        tree_container = tk.Frame(list_frame)
 288        tree_container.pack(fill=tk.BOTH, expand=True)
 289
 290        style = ttk.Style()
 291        style.configure("ActionList.Treeview",
 292                         rowheight=26,
 293                         font=("TkDefaultFont", 10))
 294        self._tree = ttk.Treeview(tree_container, selectmode="browse",
 295                                  show="tree", style="ActionList.Treeview")
 296        self._tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
 297        self._tree.bind("<<TreeviewSelect>>", self._on_select)
 298        self._tree.bind("<<TreeviewOpen>>", self._on_tree_toggle)
 299        self._tree.bind("<<TreeviewClose>>", self._on_tree_toggle)
 300
 301        # Drag-from-tree bindings
 302        self._tree.bind("<ButtonPress-1>", self._on_tree_press)
 303        self._tree.bind("<B1-Motion>", self._on_tree_drag)
 304        self._tree.bind("<ButtonRelease-1>", self._on_tree_release)
 305        self._tree.bind("<MouseWheel>", self._on_tree_scroll)
 306        self._tree.bind("<Button-4>", self._on_tree_scroll)   # Linux scroll up
 307        self._tree.bind("<Button-5>", self._on_tree_scroll)   # Linux scroll down
 308        self._tree.bind("<Delete>", lambda e: self._remove_action())
 309        self._tree.bind("<Control-d>", lambda e: self._duplicate_action())
 310
 311        tree_scroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
 312                                    command=self._tree.yview)
 313        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
 314        self._tree.configure(yscrollcommand=tree_scroll.set)
 315
 316        # Tooltip for action descriptions
 317        self._tooltip = _TreeTooltip(self._tree)
 318        self._tooltip.set_text_fn(self._get_tooltip_text)
 319
 320        # Right-click context menu
 321        self._context_menu = tk.Menu(self, tearoff=0)
 322        self._context_menu.add_command(label="Export Group...",
 323                                       command=self._on_context_export_group)
 324        self._context_menu.add_command(label="Rename Group...",
 325                                       command=self._rename_group)
 326        self._tree.bind("<Button-3>", self._on_right_click)
 327
 328        # Action buttons
 329        btn_frame = tk.Frame(list_frame)
 330        btn_frame.pack(fill=tk.X, pady=(5, 0))
 331        ttk.Button(btn_frame, text="Add", command=self._add_action,
 332                   width=8).pack(side=tk.LEFT, padx=2)
 333        ttk.Button(btn_frame, text="Remove", command=self._remove_action,
 334                   width=8).pack(side=tk.LEFT, padx=2)
 335        ttk.Button(btn_frame, text="Duplicate", command=self._duplicate_action,
 336                   width=8).pack(side=tk.LEFT, padx=2)
 337        self._assign_btn = ttk.Button(
 338            btn_frame, text="Assign...",
 339            command=self._on_assign_button, width=8)
 340        self._assign_btn.pack(side=tk.LEFT, padx=2)
 341
 342        # Group buttons
 343        group_btn_frame = tk.Frame(list_frame)
 344        group_btn_frame.pack(fill=tk.X, pady=(2, 0))
 345        ttk.Button(group_btn_frame, text="Add Group",
 346                   command=self._add_group, width=10).pack(side=tk.LEFT, padx=2)
 347        ttk.Button(group_btn_frame, text="Remove Group",
 348                   command=self._remove_group, width=12).pack(side=tk.LEFT, padx=2)
 349
 350        # --- Detail Editor ---
 351        self._detail_frame = ttk.LabelFrame(self, text="Action Details", padding=5)
 352        self._detail_frame.pack(fill=tk.X, pady=(10, 0))
 353
 354        row = 0
 355
 356        # Name
 357        self._name_label = ttk.Label(self._detail_frame, text="Name:", width=8)
 358        self._name_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 359        self._name_var = tk.StringVar()
 360        self._name_entry = ttk.Entry(self._detail_frame,
 361                                     textvariable=self._name_var, width=20)
 362        self._name_entry.grid(row=row, column=1, sticky=tk.EW, pady=2)
 363        self._name_var.trace_add("write", self._on_name_changed)
 364        self._name_entry.bind("<Return>", self._commit_name)
 365        self._name_entry.bind("<FocusOut>", self._commit_name)
 366
 367        # Group
 368        row += 1
 369        self._group_label = ttk.Label(self._detail_frame, text="Group:", width=8)
 370        self._group_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 371        self._group_var = tk.StringVar()
 372        self._group_combo = ttk.Combobox(self._detail_frame,
 373                                         textvariable=self._group_var, width=17)
 374        self._group_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 375        self._group_var.trace_add("write", self._on_group_changed)
 376        self._group_combo.bind("<Return>", self._commit_group)
 377        self._group_combo.bind("<FocusOut>", self._commit_group)
 378        self._group_combo.bind("<<ComboboxSelected>>", self._commit_group)
 379
 380        # Description (multi-line wrapped text)
 381        row += 1
 382        self._desc_label = ttk.Label(self._detail_frame, text="Description:",
 383                                         width=12)
 384        self._desc_label.grid(row=row, column=0, sticky=tk.NW, pady=2)
 385        self._desc_text = tk.Text(self._detail_frame, width=23, height=3,
 386                                  wrap=tk.WORD, font=("TkDefaultFont", 9),
 387                                  relief=tk.SUNKEN, borderwidth=1)
 388        self._desc_text.grid(row=row, column=1, sticky=tk.EW, pady=2)
 389        self._desc_text.bind("<<Modified>>", self._on_desc_modified)
 390
 391        # Input Type
 392        row += 1
 393        self._input_type_label = ttk.Label(self._detail_frame, text="Input Type:",
 394                                                 width=8, wraplength=55)
 395        self._input_type_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 396        self._input_type_var = tk.StringVar()
 397        self._input_type_combo = ttk.Combobox(
 398            self._detail_frame, textvariable=self._input_type_var,
 399            values=[t.value for t in InputType], state="readonly", width=17,
 400        )
 401        self._input_type_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 402        self._input_type_var.trace_add("write", self._on_input_type_changed)
 403
 404        # Trigger Mode
 405        row += 1
 406        self._trigger_label = ttk.Label(self._detail_frame, text="Trigger Mode:")
 407        self._trigger_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 408        self._trigger_var = tk.StringVar()
 409        self._trigger_combo = ttk.Combobox(
 410            self._detail_frame, textvariable=self._trigger_var,
 411            values=[t.value for t in BUTTON_EVENT_TRIGGER_MODES],
 412            state="readonly", width=17,
 413        )
 414        self._trigger_combo.grid(row=row, column=1, sticky=tk.EW, pady=2)
 415        self._trigger_var.trace_add("write", self._on_field_changed)
 416        self._trigger_var.trace_add("write", self._check_spline_gate)
 417
 418        # Threshold (boolean_trigger; greyed out for button)
 419        row += 1
 420        self._threshold_label = ttk.Label(
 421            self._detail_frame, text="Threshold:")
 422        self._threshold_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 423        self._threshold_var = tk.StringVar(value="0.5")
 424        self._threshold_spin = ttk.Spinbox(
 425            self._detail_frame, textvariable=self._threshold_var,
 426            from_=0.0, to=1.0, increment=0.05, width=17,
 427        )
 428        self._threshold_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 429        self._threshold_var.trace_add("write", self._on_field_changed)
 430
 431        # Deadband (axis only)
 432        row += 1
 433        self._deadband_label = ttk.Label(self._detail_frame, text="Deadband:")
 434        self._deadband_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 435        self._deadband_var = tk.StringVar(value="0.0")
 436        self._deadband_spin = ttk.Spinbox(
 437            self._detail_frame, textvariable=self._deadband_var,
 438            from_=0.0, to=1.0, increment=0.01, width=17,
 439        )
 440        self._deadband_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 441        self._deadband_var.trace_add("write", self._on_field_changed)
 442
 443        # Inversion (axis only)
 444        row += 1
 445        self._inversion_label = ttk.Label(self._detail_frame, text="Inversion:")
 446        self._inversion_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 447        self._inversion_var = tk.BooleanVar()
 448        self._inversion_check = ttk.Checkbutton(
 449            self._detail_frame, variable=self._inversion_var,
 450        )
 451        self._inversion_check.grid(row=row, column=1, sticky=tk.W, pady=2)
 452        self._inversion_var.trace_add("write", self._on_field_changed)
 453
 454        # Scale (axis only)
 455        row += 1
 456        self._scale_label = ttk.Label(self._detail_frame, text="Scale:")
 457        self._scale_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 458        self._scale_var = tk.StringVar(value="1.0")
 459        self._scale_spin = ttk.Spinbox(
 460            self._detail_frame, textvariable=self._scale_var,
 461            from_=-10.0, to=10.0, increment=0.1, width=17,
 462        )
 463        self._scale_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 464        self._scale_var.trace_add("write", self._on_field_changed)
 465
 466        # Slew rate (axis only)
 467        row += 1
 468        self._slew_label = ttk.Label(self._detail_frame, text="Slew Rate:")
 469        self._slew_label.grid(row=row, column=0, sticky=tk.W, pady=2)
 470        self._slew_var = tk.StringVar(value="0.0")
 471        self._slew_spin = ttk.Spinbox(
 472            self._detail_frame, textvariable=self._slew_var,
 473            from_=0.0, to=100.0, increment=0.1, width=17,
 474        )
 475        self._slew_spin.grid(row=row, column=1, sticky=tk.EW, pady=2)
 476        self._slew_var.trace_add("write", self._on_field_changed)
 477
 478        # Negative slew rate (axis only, stored in extra)
 479        row += 1
 480        self._neg_slew_frame = ttk.Frame(self._detail_frame)
 481        self._neg_slew_frame.grid(row=row, column=0, columnspan=2,
 482                                  sticky=tk.EW, pady=2)
 483        self._neg_slew_enable_var = tk.BooleanVar(value=False)
 484        self._neg_slew_check = ttk.Checkbutton(
 485            self._neg_slew_frame, text="Neg. Slew Rate:",
 486            variable=self._neg_slew_enable_var,
 487        )
 488        self._neg_slew_check.pack(side=tk.LEFT)
 489        self._neg_slew_var = tk.StringVar(value="0.0")
 490        self._neg_slew_spin = ttk.Spinbox(
 491            self._neg_slew_frame, textvariable=self._neg_slew_var,
 492            from_=-100.0, to=0.0, increment=0.1, width=10,
 493        )
 494        self._neg_slew_spin.pack(side=tk.LEFT, fill=tk.X, expand=True)
 495        self._neg_slew_spin.config(state="disabled")
 496        self._neg_slew_enable_var.trace_add("write", self._on_neg_slew_toggled)
 497        self._neg_slew_var.trace_add("write", self._on_field_changed)
 498
 499        # Spline controls (visible only for ANALOG + SPLINE trigger mode)
 500        row += 1
 501        self._edit_spline_btn = ttk.Button(
 502            self._detail_frame, text="Edit Spline...",
 503            command=self._on_edit_spline,
 504        )
 505        self._edit_spline_btn.grid(row=row, column=0, columnspan=2,
 506                                    sticky=tk.EW, pady=2)
 507
 508        # Segment controls (visible only for ANALOG + SEGMENTED trigger mode)
 509        row += 1
 510        self._edit_segments_btn = ttk.Button(
 511            self._detail_frame, text="Edit Segments...",
 512            command=self._on_edit_segments,
 513        )
 514        self._edit_segments_btn.grid(row=row, column=0, columnspan=2,
 515                                     sticky=tk.EW, pady=2)
 516
 517        # --- Virtual Analog fields (visible only for VIRTUAL_ANALOG) ---
 518        # Compact 4-column grid inside a single frame to save vertical space.
 519
 520        row += 1
 521        self._va_outer_frame = ttk.Frame(self._detail_frame)
 522        self._va_outer_frame.grid(row=row, column=0, columnspan=2,
 523                                  sticky=tk.EW, pady=2)
 524        vr = 0  # row counter inside VA frame
 525
 526        # Row 0: Button Mode
 527        ttk.Label(self._va_outer_frame, text="Mode:").grid(
 528            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 529        self._va_mode_var = tk.StringVar(value="held")
 530        self._va_mode_combo = ttk.Combobox(
 531            self._va_outer_frame, textvariable=self._va_mode_var,
 532            values=["held", "toggle"], state="readonly", width=7,
 533        )
 534        self._va_mode_combo.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 535        self._va_mode_var.trace_add("write", self._on_field_changed)
 536
 537        # Row 1: Ramp Rate | Acceleration (mutually exclusive)
 538        vr += 1
 539        ttk.Label(self._va_outer_frame, text="Ramp Rate:").grid(
 540            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 541        self._va_ramp_var = tk.StringVar(value="1.0")
 542        self._va_ramp_spin = ttk.Spinbox(
 543            self._va_outer_frame, textvariable=self._va_ramp_var,
 544            from_=0.0, to=100.0, increment=0.1, width=7,
 545        )
 546        self._va_ramp_spin.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 547        self._va_ramp_var.trace_add("write", self._on_field_changed)
 548
 549        ttk.Label(self._va_outer_frame, text="Accel:").grid(
 550            row=vr, column=2, sticky=tk.W, padx=(0, 2))
 551        self._va_accel_var = tk.StringVar(value="0.0")
 552        self._va_accel_spin = ttk.Spinbox(
 553            self._va_outer_frame, textvariable=self._va_accel_var,
 554            from_=0.0, to=100.0, increment=0.1, width=7,
 555        )
 556        self._va_accel_spin.grid(row=vr, column=3, sticky=tk.EW)
 557        self._va_accel_var.trace_add("write", self._on_field_changed)
 558
 559        # Row 1: Target | Rest
 560        vr += 1
 561        ttk.Label(self._va_outer_frame, text="Target:").grid(
 562            row=vr, column=0, sticky=tk.W, padx=(0, 2))
 563        self._va_target_var = tk.StringVar(value="1.0")
 564        self._va_target_spin = ttk.Spinbox(
 565            self._va_outer_frame, textvariable=self._va_target_var,
 566            from_=-10.0, to=10.0, increment=0.1, width=7,
 567        )
 568        self._va_target_spin.grid(row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 569        self._va_target_var.trace_add("write", self._on_field_changed)
 570
 571        ttk.Label(self._va_outer_frame, text="Rest:").grid(
 572            row=vr, column=2, sticky=tk.W, padx=(0, 2))
 573        self._va_rest_var = tk.StringVar(value="0.0")
 574        self._va_rest_spin = ttk.Spinbox(
 575            self._va_outer_frame, textvariable=self._va_rest_var,
 576            from_=-10.0, to=10.0, increment=0.1, width=7,
 577        )
 578        self._va_rest_spin.grid(row=vr, column=3, sticky=tk.EW)
 579        self._va_rest_var.trace_add("write", self._on_field_changed)
 580
 581        # Row 2: Zero Vel on Release checkbox
 582        vr += 1
 583        self._va_zero_vel_var = tk.BooleanVar(value=False)
 584        self._va_zero_vel_check = ttk.Checkbutton(
 585            self._va_outer_frame, text="Zero vel on release",
 586            variable=self._va_zero_vel_var,
 587        )
 588        self._va_zero_vel_check.grid(
 589            row=vr, column=0, columnspan=4, sticky=tk.W, pady=2)
 590        self._va_zero_vel_var.trace_add("write", self._on_field_changed)
 591
 592        # Row 3: Neg Ramp Rate | Neg Acceleration (optional overrides)
 593        vr += 1
 594        self._va_neg_ramp_enable_var = tk.BooleanVar(value=False)
 595        self._va_neg_ramp_check = ttk.Checkbutton(
 596            self._va_outer_frame, text="Neg Ramp:",
 597            variable=self._va_neg_ramp_enable_var,
 598        )
 599        self._va_neg_ramp_check.grid(row=vr, column=0, sticky=tk.W)
 600        self._va_neg_ramp_var = tk.StringVar(value="0.0")
 601        self._va_neg_ramp_spin = ttk.Spinbox(
 602            self._va_outer_frame, textvariable=self._va_neg_ramp_var,
 603            from_=0.0, to=100.0, increment=0.1, width=7,
 604        )
 605        self._va_neg_ramp_spin.grid(
 606            row=vr, column=1, sticky=tk.EW, padx=(0, 6))
 607        self._va_neg_ramp_spin.config(state="disabled")
 608        self._va_neg_ramp_enable_var.trace_add(
 609            "write", self._on_va_neg_ramp_toggled)
 610        self._va_neg_ramp_var.trace_add("write", self._on_field_changed)
 611
 612        self._va_neg_accel_enable_var = tk.BooleanVar(value=False)
 613        self._va_neg_accel_check = ttk.Checkbutton(
 614            self._va_outer_frame, text="Neg Accel:",
 615            variable=self._va_neg_accel_enable_var,
 616        )
 617        self._va_neg_accel_check.grid(row=vr, column=2, sticky=tk.W)
 618        self._va_neg_accel_var = tk.StringVar(value="0.0")
 619        self._va_neg_accel_spin = ttk.Spinbox(
 620            self._va_outer_frame, textvariable=self._va_neg_accel_var,
 621            from_=0.0, to=100.0, increment=0.1, width=7,
 622        )
 623        self._va_neg_accel_spin.grid(row=vr, column=3, sticky=tk.EW)
 624        self._va_neg_accel_spin.config(state="disabled")
 625        self._va_neg_accel_enable_var.trace_add(
 626            "write", self._on_va_neg_accel_toggled)
 627        self._va_neg_accel_var.trace_add("write", self._on_field_changed)
 628
 629        # Let spinbox columns stretch
 630        self._va_outer_frame.columnconfigure(1, weight=1)
 631        self._va_outer_frame.columnconfigure(3, weight=1)
 632
 633        self._detail_frame.columnconfigure(1, weight=1)
 634
 635        # Analog-only widgets for show/hide
 636        self._axis_widgets = [
 637            (self._deadband_label, self._deadband_spin),
 638            (self._inversion_label, self._inversion_check),
 639            (self._scale_label, self._scale_spin),
 640            (self._slew_label, self._slew_spin),
 641        ]
 642        # Neg slew frame handled separately (single frame spanning both columns)
 643        self._neg_slew_widgets = [self._neg_slew_frame]
 644
 645        # Spline-only widgets for show/hide
 646        self._spline_widgets = [self._edit_spline_btn]
 647        self._edit_spline_btn.grid_remove()
 648
 649        # Segment-only widgets for show/hide
 650        self._segment_widgets = [self._edit_segments_btn]
 651        self._edit_segments_btn.grid_remove()
 652
 653        # Initially hide VA frame
 654        self._va_outer_frame.grid_remove()
 655
 656        # Tooltips for detail form fields
 657        _WidgetTooltip(self._name_label, TIP_NAME)
 658        _WidgetTooltip(self._name_entry, TIP_NAME)
 659        _WidgetTooltip(self._group_label, TIP_GROUP)
 660        _WidgetTooltip(self._group_combo, TIP_GROUP)
 661        _WidgetTooltip(self._desc_label, TIP_DESC)
 662        _WidgetTooltip(self._desc_text, TIP_DESC)
 663        _WidgetTooltip(self._input_type_label, TIP_INPUT_TYPE)
 664        _WidgetTooltip(self._input_type_combo, TIP_INPUT_TYPE)
 665        self._trigger_tooltip = _WidgetTooltip(
 666            self._trigger_label, TIP_TRIGGER)
 667        _WidgetTooltip(self._trigger_combo, TIP_TRIGGER)
 668        _WidgetTooltip(self._threshold_label, TIP_THRESHOLD)
 669        _WidgetTooltip(self._threshold_spin, TIP_THRESHOLD)
 670        _WidgetTooltip(self._deadband_label, TIP_DEADBAND)
 671        _WidgetTooltip(self._deadband_spin, TIP_DEADBAND)
 672        _WidgetTooltip(self._inversion_label, TIP_INVERSION)
 673        _WidgetTooltip(self._inversion_check, TIP_INVERSION)
 674        _WidgetTooltip(self._scale_label, TIP_SCALE)
 675        _WidgetTooltip(self._scale_spin, TIP_SCALE)
 676        _WidgetTooltip(self._slew_label, TIP_SLEW)
 677        _WidgetTooltip(self._slew_spin, TIP_SLEW)
 678        _WidgetTooltip(self._neg_slew_check, TIP_NEG_SLEW)
 679        _WidgetTooltip(self._neg_slew_spin, TIP_NEG_SLEW)
 680        _WidgetTooltip(self._edit_spline_btn, TIP_EDIT_SPLINE)
 681        _WidgetTooltip(self._edit_segments_btn, TIP_EDIT_SEGMENTS)
 682        # VA field tooltips
 683        _WidgetTooltip(self._va_mode_combo, TIP_VA_BUTTON_MODE)
 684        _WidgetTooltip(self._va_ramp_spin, TIP_VA_RAMP_RATE)
 685        _WidgetTooltip(self._va_accel_spin, TIP_VA_ACCELERATION)
 686        _WidgetTooltip(self._va_target_spin, TIP_VA_TARGET)
 687        _WidgetTooltip(self._va_rest_spin, TIP_VA_REST)
 688        _WidgetTooltip(self._va_zero_vel_check, TIP_VA_ZERO_VEL)
 689        _WidgetTooltip(self._va_neg_ramp_check, TIP_VA_NEG_RAMP)
 690        _WidgetTooltip(self._va_neg_ramp_spin, TIP_VA_NEG_RAMP)
 691        _WidgetTooltip(self._va_neg_accel_check, TIP_VA_NEG_ACCEL)
 692        _WidgetTooltip(self._va_neg_accel_spin, TIP_VA_NEG_ACCEL)
 693        # Filter bar tooltips
 694        _WidgetTooltip(self._filter_entry, TIP_FILTER)
 695        _WidgetTooltip(self._filter_unassigned_cb, TIP_FILTER_UNASSIGNED)
 696        _WidgetTooltip(self._filter_multi_cb, TIP_FILTER_MULTI)
 697
 698        self._set_detail_enabled(False)
 699
 700    # ------------------------------------------------------------------
 701    # Custom-settings tracking
 702    # ------------------------------------------------------------------
 703
 704    @staticmethod
 705    def _is_action_custom(action: ActionDefinition) -> bool:
 706        """Check if an action has any non-default field values.
 707
 708        Used on load to tag actions that were customized in the YAML.
 709        """
 710        if action.input_type == InputType.ANALOG:
 711            default_trigger = EventTriggerMode.SCALED
 712        else:
 713            default_trigger = EventTriggerMode.ON_TRUE
 714        return (
 715            action.deadband > 0.01
 716            or action.inversion
 717            or abs(action.scale - 1.0) > 0.01
 718            or action.slew_rate > 0.01
 719            or action.trigger_mode != default_trigger
 720            or action.extra.get(EXTRA_SPLINE_POINTS)
 721            or action.extra.get(EXTRA_SEGMENT_POINTS)
 722            or action.extra.get(EXTRA_NEGATIVE_SLEW_RATE) is not None
 723        )
 724
 725    def _tag_actions_custom(self):
 726        """Set _has_custom on all actions from current field values."""
 727        for action in self._actions.values():
 728            action._has_custom = self._is_action_custom(action)
 729
 730    # ------------------------------------------------------------------
 731    # Public API
 732    # ------------------------------------------------------------------
 733
 734    def set_details_editable(self, enabled: bool):
 735        """Enable or disable editing of Action Details fields.
 736
 737        When disabled, all detail form fields become display-only.
 738        """
 739        self._details_editable = enabled
 740        if self._selected_name:
 741            self._apply_details_editable()
 742
 743    def _apply_details_editable(self):
 744        """Apply the current _details_editable state to detail widgets."""
 745        # Re-run _set_detail_enabled which checks _details_editable
 746        self._set_detail_enabled(True)
 747
 748    def on_advanced_changed(self):
 749        """Refresh UI elements affected by Advanced menu toggles."""
 750        if self._selected_name:
 751            action = self._actions.get(self._selected_name)
 752            if action and action.input_type == InputType.ANALOG:
 753                self._refresh_spline_gate()
 754
 755    def set_actions(self, actions: dict[str, ActionDefinition]):
 756        """Load a full set of actions (e.g., from file)."""
 757        self._actions = dict(actions)
 758        self._tag_actions_custom()
 759        self._empty_groups = set()
 760        self._refresh_tree()
 761        self._selected_name = None
 762        self._set_detail_enabled(False)
 763
 764    def get_actions(self) -> dict[str, ActionDefinition]:
 765        """Return the current actions dict keyed by qualified name."""
 766        return dict(self._actions)
 767
 768    def get_action_names(self) -> list[str]:
 769        """Return sorted list of fully qualified action names."""
 770        return sorted(self._actions.keys())
 771
 772    def get_empty_groups(self) -> set[str]:
 773        """Return a copy of the empty-group set (for undo snapshots)."""
 774        return set(self._empty_groups)
 775
 776    def set_empty_groups(self, groups: set[str]):
 777        """Restore the empty-group set (for undo restore)."""
 778        self._empty_groups = set(groups)
 779        self._refresh_tree()
 780
 781    # ------------------------------------------------------------------
 782    # Tree Management
 783    # ------------------------------------------------------------------
 784
 785    def _collect_groups(self) -> dict[str, list[str]]:
 786        """Collect group -> [qualified_name, ...] mapping.
 787
 788        The "general" group is always included so actions can be
 789        assigned to it even when it has no members.
 790        """
 791        groups: dict[str, list[str]] = {DEFAULT_GROUP: []}
 792        for qname, action in self._actions.items():
 793            groups.setdefault(action.group, []).append(qname)
 794        for g in self._empty_groups:
 795            if g not in groups:
 796                groups[g] = []
 797        return groups
 798
 799    def _sorted_group_names(self, groups: dict[str, list[str]]) -> list[str]:
 800        """Sort group names with 'general' first."""
 801        return sorted(groups.keys(), key=lambda g: (g != DEFAULT_GROUP, g))
 802
 803    def get_group_names(self) -> list[str]:
 804        """Return sorted list of all group names (including empty/default).
 805
 806        Single source of truth for group names used by both the
 807        ActionPanel and ActionEditorTab group dropdowns.
 808        """
 809        return self._sorted_group_names(self._collect_groups())
 810
 811    # Prefix for placeholder items in empty groups
 812    _EMPTY_PREFIX = "empty::"
 813
 814    def _refresh_tree(self):
 815        """Rebuild the treeview from the actions dict."""
 816        self._tree.delete(*self._tree.get_children())
 817
 818        groups = self._collect_groups()
 819        sorted_groups = self._sorted_group_names(groups)
 820        filt = self._get_filter_text()
 821        status_active = (self._filter_unassigned_var.get()
 822                         or self._filter_multi_var.get())
 823
 824        for group in sorted_groups:
 825            group_iid = f"{self._GROUP_PREFIX}{group}"
 826            members = groups[group]
 827
 828            # Apply text filter: keep actions matching name/group/description
 829            if filt:
 830                members = [
 831                    q for q in members
 832                    if self._matches_filter(q, filt)
 833                ]
 834                # Skip groups with no matching actions (unless group
 835                # name itself matches)
 836                if not members and filt not in group.lower():
 837                    continue
 838
 839            # Apply binding status filter
 840            if status_active:
 841                members = [
 842                    q for q in members
 843                    if self._matches_status_filter(q)
 844                ]
 845                if not members:
 846                    continue
 847
 848            has_actions = bool(members)
 849            self._tree.insert("", tk.END, iid=group_iid,
 850                              text=f" {group}", open=has_actions,
 851                              tags=("group",))
 852
 853            if has_actions:
 854                for qname in sorted(members,
 855                                    key=lambda q: q.split(".", 1)[-1]):
 856                    action = self._actions[qname]
 857                    self._tree.insert(group_iid, tk.END, iid=qname,
 858                                      text=f"  {action.name}",
 859                                      tags=("action",))
 860            else:
 861                # Placeholder so the +/- indicator appears
 862                self._tree.insert(
 863                    group_iid, tk.END,
 864                    iid=f"{self._EMPTY_PREFIX}{group}",
 865                    text="  (empty)", tags=("empty_placeholder",))
 866
 867        # Update group combo values
 868        self._group_combo['values'] = sorted_groups
 869
 870        # Style rows
 871        self._tree.tag_configure("group", font=("TkDefaultFont", 10, "bold"))
 872        self._tree.tag_configure("action", font=("TkDefaultFont", 10))
 873        self._tree.tag_configure("empty_placeholder",
 874                                 foreground="#999999",
 875                                 font=("TkDefaultFont", 10, "italic"))
 876
 877        self.update_binding_tags()
 878
 879    def update_binding_tags(self):
 880        """Update action item background colors based on binding status.
 881
 882        Called after bindings change (drag-drop, dialog, undo, file load).
 883        - Unassigned actions get a faint red background.
 884        - Actions bound to more than one input get a faint yellow background.
 885        - Collapsed groups reflect child status: red (unassigned), yellow
 886          (duplicate-bound), or orange (both).
 887        """
 888        self._tree.tag_configure("unassigned",
 889                                 background="#ffdddd",
 890                                 font=("TkDefaultFont", 10))
 891        self._tree.tag_configure("multi_bound",
 892                                 background="#ffffcc",
 893                                 font=("TkDefaultFont", 10))
 894        self._tree.tag_configure("action",
 895                                 background="",
 896                                 font=("TkDefaultFont", 10))
 897        # Group-level status tags (shown when collapsed)
 898        self._tree.tag_configure("group_unassigned",
 899                                 background="#ffdddd",
 900                                 font=("TkDefaultFont", 10, "bold"))
 901        self._tree.tag_configure("group_multi_bound",
 902                                 background="#ffffcc",
 903                                 font=("TkDefaultFont", 10, "bold"))
 904        self._tree.tag_configure("group_mixed",
 905                                 background="#ffddbb",
 906                                 font=("TkDefaultFont", 10, "bold"))
 907
 908        if not self._get_binding_info:
 909            return
 910
 911        # Track per-group status flags
 912        group_has_unassigned: dict[str, bool] = {}
 913        group_has_multi: dict[str, bool] = {}
 914
 915        # Clear old icon refs before rebuilding
 916        self._tree_icons.clear()
 917
 918        for qname, action in self._actions.items():
 919            if not self._tree.exists(qname):
 920                continue
 921            bindings = self._get_binding_info(qname)
 922            if not bindings:
 923                self._tree.item(qname, tags=("unassigned",))
 924                group_has_unassigned[action.group] = True
 925            elif len(bindings) > 1:
 926                self._tree.item(qname, tags=("multi_bound",))
 927                group_has_multi[action.group] = True
 928            else:
 929                self._tree.item(qname, tags=("action",))
 930
 931            # Set icon from first binding's input name
 932            icon = None
 933            if bindings and self._icon_loader:
 934                input_name = bindings[0][2]  # (ctrl, display, input_name)
 935                icon = self._icon_loader.get_tk_icon(input_name, 20)
 936            if icon:
 937                self._tree_icons.append(icon)
 938                self._tree.item(qname, image=icon)
 939            else:
 940                self._tree.item(qname, image="")
 941
 942        # Apply status colors to collapsed group nodes
 943        self._update_group_tags(group_has_unassigned, group_has_multi)
 944
 945    def _update_group_tags(self, group_has_unassigned: dict[str, bool],
 946                           group_has_multi: dict[str, bool]):
 947        """Set group node tags based on child status and collapsed state."""
 948        for group_iid in self._tree.get_children(""):
 949            if not group_iid.startswith(self._GROUP_PREFIX):
 950                continue
 951            group_name = group_iid[len(self._GROUP_PREFIX):]
 952            is_open = self._tree.item(group_iid, "open")
 953            has_unassigned = group_has_unassigned.get(group_name, False)
 954            has_multi = group_has_multi.get(group_name, False)
 955
 956            if not is_open and has_unassigned and has_multi:
 957                self._tree.item(group_iid, tags=("group_mixed",))
 958            elif not is_open and has_unassigned:
 959                self._tree.item(group_iid, tags=("group_unassigned",))
 960            elif not is_open and has_multi:
 961                self._tree.item(group_iid, tags=("group_multi_bound",))
 962            else:
 963                self._tree.item(group_iid, tags=("group",))
 964
 965    def _on_tree_toggle(self, event):
 966        """Handle group expand/collapse — refresh group background colors."""
 967        self.update_binding_tags()
 968
 969    # ------------------------------------------------------------------
 970    # Filter
 971    # ------------------------------------------------------------------
 972
 973    def _get_filter_text(self) -> str:
 974        """Return the active filter string, or '' if placeholder is showing."""
 975        if self._filter_placeholder:
 976            return ""
 977        return self._filter_var.get().strip().lower()
 978
 979    def _on_filter_changed(self, *args):
 980        if self._filter_placeholder:
 981            return
 982        self._refresh_tree()
 983
 984    def _clear_filter(self):
 985        self._filter_var.set("")
 986        self._filter_unassigned_var.set(False)
 987        self._filter_multi_var.set(False)
 988        self._refresh_tree()
 989        self._tree.focus_set()
 990
 991    def _on_filter_focus_in(self, event):
 992        if self._filter_placeholder:
 993            self._filter_placeholder = False
 994            self._filter_entry.delete(0, tk.END)
 995            self._filter_entry.config(foreground="")
 996
 997    def _on_filter_focus_out(self, event):
 998        if not self._filter_var.get():
 999            self._filter_placeholder = True
1000            self._filter_entry.insert(0, "Filter actions...")
1001            self._filter_entry.config(foreground="grey")
1002
1003    def _on_status_filter_changed(self):
1004        """Handle unassigned/multi-bound filter toggle."""
1005        self._refresh_tree()
1006
1007    def _matches_filter(self, qname: str, filt: str) -> bool:
1008        """Check if an action matches the filter text.
1009
1010        Supports glob wildcards (* and ?) when present in the filter.
1011        Falls back to substring matching otherwise.
1012        """
1013        action = self._actions.get(qname)
1014        if not action:
1015            return False
1016        fields = (action.name.lower(), action.group.lower(),
1017                  action.description.lower())
1018        if '*' in filt or '?' in filt:
1019            # Auto-append * so users don't need to match through
1020            # end of string: "de*p" matches "deploy"
1021            pattern = filt if filt.endswith(('*', '?')) else filt + '*'
1022            return any(fnmatch.fnmatch(f, pattern) for f in fields)
1023        return any(filt in f for f in fields)
1024
1025    def _matches_status_filter(self, qname: str) -> bool:
1026        """Check if an action passes the binding status filter.
1027
1028        When neither toggle is active, all actions pass.
1029        When one or both are active, the action must match at least
1030        one active filter (OR logic).
1031        """
1032        want_unassigned = self._filter_unassigned_var.get()
1033        want_multi = self._filter_multi_var.get()
1034        if not want_unassigned and not want_multi:
1035            return True
1036        if not self._get_binding_info:
1037            return True
1038        bindings = self._get_binding_info(qname)
1039        if want_unassigned and not bindings:
1040            return True
1041        if want_multi and len(bindings) > 1:
1042            return True
1043        return False
1044
1045    # ------------------------------------------------------------------
1046    # Selection Handling
1047    # ------------------------------------------------------------------
1048
1049    def _on_select(self, event):
1050        """Handle tree selection change."""
1051        sel = self._tree.selection()
1052        if not sel:
1053            self._selected_name = None
1054            self._set_detail_enabled(False)
1055            self._notify_selection_changed()
1056            return
1057
1058        item_id = sel[0]
1059        if (item_id.startswith(self._GROUP_PREFIX)
1060                or item_id.startswith(self._EMPTY_PREFIX)):
1061            self._selected_name = None
1062            self._set_detail_enabled(False)
1063            self._notify_selection_changed()
1064            return
1065
1066        self._selected_name = item_id
1067        self._load_detail(item_id)
1068        self._set_detail_enabled(True)
1069        self._notify_selection_changed()
1070
1071    def _notify_selection_changed(self):
1072        """Notify external listeners of the current selection."""
1073        if self._on_selection_changed:
1074            self._on_selection_changed(self._selected_name)
1075
1076    def _load_detail(self, qname: str):
1077        """Populate the detail form from an action."""
1078        action = self._actions.get(qname)
1079        if not action:
1080            return
1081
1082        self._updating_form = True
1083        try:
1084            self._name_var.set(action.name)
1085            self._group_var.set(action.group)
1086            self._desc_text.delete("1.0", tk.END)
1087            self._desc_text.insert("1.0", action.description)
1088            self._desc_text.edit_modified(False)
1089            self._input_type_var.set(action.input_type.value)
1090            self._update_trigger_mode_options(action.input_type)
1091            self._trigger_var.set(action.trigger_mode.value)
1092            self._threshold_var.set(str(action.threshold))
1093            self._deadband_var.set(str(action.deadband))
1094            self._inversion_var.set(action.inversion)
1095            self._scale_var.set(str(action.scale))
1096            self._slew_var.set(str(action.slew_rate))
1097            neg_slew = action.extra.get(EXTRA_NEGATIVE_SLEW_RATE)
1098            if neg_slew is not None:
1099                self._neg_slew_enable_var.set(True)
1100                self._neg_slew_var.set(str(min(float(neg_slew), 0.0)))
1101            else:
1102                self._neg_slew_enable_var.set(False)
1103                self._neg_slew_var.set("0.0")
1104            # Virtual Analog fields
1105            self._va_mode_var.set(
1106                action.extra.get(EXTRA_VA_BUTTON_MODE, "held"))
1107            self._va_ramp_var.set(
1108                str(action.extra.get(EXTRA_VA_RAMP_RATE, 0.0)))
1109            self._va_accel_var.set(
1110                str(action.extra.get(EXTRA_VA_ACCELERATION, 0.0)))
1111            self._va_target_var.set(
1112                str(action.extra.get(EXTRA_VA_TARGET_VALUE, 1.0)))
1113            self._va_rest_var.set(
1114                str(action.extra.get(EXTRA_VA_REST_VALUE, 0.0)))
1115            self._va_zero_vel_var.set(
1116                bool(action.extra.get(EXTRA_VA_ZERO_VEL_ON_RELEASE, False)))
1117            neg_ramp = action.extra.get(EXTRA_VA_NEGATIVE_RAMP_RATE)
1118            if neg_ramp is not None:
1119                self._va_neg_ramp_enable_var.set(True)
1120                self._va_neg_ramp_var.set(str(float(neg_ramp)))
1121            else:
1122                self._va_neg_ramp_enable_var.set(False)
1123                self._va_neg_ramp_var.set("0.0")
1124            neg_accel = action.extra.get(EXTRA_VA_NEGATIVE_ACCELERATION)
1125            if neg_accel is not None:
1126                self._va_neg_accel_enable_var.set(True)
1127                self._va_neg_accel_var.set(str(float(neg_accel)))
1128            else:
1129                self._va_neg_accel_enable_var.set(False)
1130                self._va_neg_accel_var.set("0.0")
1131        finally:
1132            self._updating_form = False
1133
1134        self._update_type_visibility()
1135
1136    def _set_detail_enabled(self, enabled: bool):
1137        """Enable or disable the detail form.
1138
1139        When *enabled* is True but ``_details_editable`` is False, the form
1140        fields remain disabled (display-only).
1141        """
1142        # If the form is being enabled but detail editing is locked,
1143        # force display-only mode.
1144        effective = enabled and self._details_editable
1145        state = "normal" if effective else "disabled"
1146        readonly_state = "readonly" if effective else "disabled"
1147        self._name_entry.config(state=state)
1148        self._group_combo.config(state=state)
1149        for child in self._detail_frame.winfo_children():
1150            if isinstance(child, (ttk.Entry, ttk.Spinbox)):
1151                child.config(state=state)
1152            elif isinstance(child, ttk.Combobox):
1153                child.config(state=readonly_state)
1154            elif isinstance(child, (ttk.Checkbutton, ttk.Button)):
1155                child.config(state=state)
1156        # Handle children inside nested sub-frames
1157        for frame in (self._neg_slew_frame,
1158                      self._va_outer_frame):
1159            for child in frame.winfo_children():
1160                if isinstance(child, (ttk.Spinbox, ttk.Checkbutton)):
1161                    child.config(state=state)
1162        # The group combo is editable (not readonly) so users can type new names
1163        if effective:
1164            self._group_combo.config(state=state)
1165        # Also update desc text widget (not a ttk widget, skipped above)
1166        self._desc_text.config(
1167            state="normal" if effective else "disabled")
1168
1169    def _update_trigger_mode_options(self, input_type: InputType):
1170        """Update the trigger mode dropdown to show modes for the current input type.
1171
1172        Output actions have no trigger mode — the row is hidden instead.
1173        """
1174        if input_type == InputType.OUTPUT:
1175            # Hide trigger mode entirely for outputs
1176            self._trigger_label.grid_remove()
1177            self._trigger_combo.grid_remove()
1178            return
1179
1180        # Ensure trigger row is visible
1181        self._trigger_label.grid()
1182        self._trigger_combo.grid()
1183
1184        if input_type in (InputType.ANALOG, InputType.VIRTUAL_ANALOG):
1185            modes = ANALOG_EVENT_TRIGGER_MODES
1186            default = EventTriggerMode.SCALED
1187        else:
1188            modes = BUTTON_EVENT_TRIGGER_MODES
1189            default = EventTriggerMode.ON_TRUE
1190
1191        current = self._trigger_var.get()
1192        flags = self._get_advanced_flags()
1193        values = []
1194        for m in modes:
1195            label = m.value
1196            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1197                    and current != EventTriggerMode.SPLINE.value):
1198                label += _SPLINE_ADV_SUFFIX
1199            values.append(label)
1200        self._trigger_combo['values'] = values
1201
1202        # If current selection isn't valid for the new type, reset to default
1203        clean_values = [m.value for m in modes]
1204        if current not in clean_values:
1205            self._trigger_var.set(default.value)
1206
1207    def _refresh_spline_gate(self):
1208        """Update trigger combo values to reflect current spline gate state."""
1209        flags = self._get_advanced_flags()
1210        current = self._trigger_var.get()
1211        values = []
1212        for m in ANALOG_EVENT_TRIGGER_MODES:
1213            label = m.value
1214            if (m == EventTriggerMode.SPLINE and not flags["splines"]
1215                    and current != EventTriggerMode.SPLINE.value):
1216                label += _SPLINE_ADV_SUFFIX
1217            values.append(label)
1218        self._trigger_combo['values'] = values
1219
1220    def _check_spline_gate(self, *args):
1221        """Revert spline selection if splines are disabled."""
1222        if self._updating_form:
1223            return
1224        val = self._trigger_var.get()
1225        if val.endswith(_SPLINE_ADV_SUFFIX):
1226            action = (self._actions.get(self._selected_name)
1227                      if self._selected_name else None)
1228            self._updating_form = True
1229            self._trigger_var.set(
1230                action.trigger_mode.value
1231                if action else EventTriggerMode.SCALED.value)
1232            self._updating_form = False
1233            messagebox.showinfo(
1234                "Advanced Feature",
1235                "Enable splines in Advanced menu to use this mode.")
1236
1237    def _update_type_visibility(self):
1238        """Show/hide fields based on input type and trigger mode.
1239
1240        Analog-specific fields (deadband, inversion, scale, slew) only shown
1241        for analog.  When trigger mode is RAW, axis fields are visible but
1242        disabled (greyed out) since RAW bypasses all shaping.
1243        Spline controls only shown for analog + spline trigger mode.
1244        Trigger mode hidden for output actions.
1245        """
1246        input_type_str = self._input_type_var.get()
1247        is_analog = input_type_str == InputType.ANALOG.value
1248        is_va = input_type_str == InputType.VIRTUAL_ANALOG.value
1249        is_bool_trigger = input_type_str == InputType.BOOLEAN_TRIGGER.value
1250        is_button = input_type_str == InputType.BUTTON.value
1251        # VA output goes through the analog shaping pipeline
1252        show_axis = is_analog or is_va
1253
1254        # Threshold: visible for BUTTON (disabled) and BOOLEAN_TRIGGER (enabled)
1255        if is_button or is_bool_trigger:
1256            self._threshold_label.grid()
1257            self._threshold_spin.grid()
1258            self._threshold_spin.config(
1259                state="normal" if is_bool_trigger else "disabled")
1260        else:
1261            self._threshold_label.grid_remove()
1262            self._threshold_spin.grid_remove()
1263
1264        for label, widget in self._axis_widgets:
1265            if show_axis:
1266                label.grid()
1267                widget.grid()
1268            else:
1269                label.grid_remove()
1270                widget.grid_remove()
1271
1272        # Neg slew frame: show/hide with other axis widgets
1273        for w in self._neg_slew_widgets:
1274            if show_axis:
1275                w.grid()
1276            else:
1277                w.grid_remove()
1278
1279        # VA-specific frame
1280        if is_va:
1281            self._va_outer_frame.grid()
1282        else:
1283            self._va_outer_frame.grid_remove()
1284
1285        # Refresh combo values to re-gate SPLINE based on current trigger
1286        if show_axis:
1287            self._refresh_spline_gate()
1288
1289        # Spline controls: visible only for ANALOG + SPLINE
1290        trigger_str = self._trigger_var.get()
1291        show_spline = (is_analog
1292                       and trigger_str == EventTriggerMode.SPLINE.value)
1293        for w in self._spline_widgets:
1294            if show_spline:
1295                w.grid()
1296            else:
1297                w.grid_remove()
1298
1299        # Segment controls: visible only for ANALOG + SEGMENTED
1300        show_segments = (is_analog
1301                         and trigger_str == EventTriggerMode.SEGMENTED.value)
1302        for w in self._segment_widgets:
1303            if show_segments:
1304                w.grid()
1305            else:
1306                w.grid_remove()
1307
1308        # Disable axis fields when trigger mode is RAW (bypasses all shaping)
1309        if show_axis:
1310            is_raw = trigger_str == EventTriggerMode.RAW.value
1311            raw_state = "disabled" if is_raw else "normal"
1312            self._deadband_spin.config(state=raw_state)
1313            self._inversion_check.config(state=raw_state)
1314            self._scale_spin.config(state=raw_state)
1315            self._slew_spin.config(state=raw_state)
1316            self._neg_slew_check.config(state=raw_state)
1317            if is_raw:
1318                self._neg_slew_spin.config(state="disabled")
1319            else:
1320                neg_state = ("normal" if self._neg_slew_enable_var.get()
1321                             else "disabled")
1322                self._neg_slew_spin.config(state=neg_state)
1323
1324    # ------------------------------------------------------------------
1325    # Detail Form Changes
1326    # ------------------------------------------------------------------
1327
1328    def _save_detail(self):
1329        """Save the detail form back to the action. Returns True if saved."""
1330        if self._updating_form or self._selected_name is None:
1331            return False
1332
1333        action = self._actions.get(self._selected_name)
1334        if not action:
1335            return False
1336
1337        try:
1338            action.description = self._desc_text.get(
1339                "1.0", "end-1c").strip()
1340            action.input_type = InputType(self._input_type_var.get())
1341            if action.input_type == InputType.OUTPUT:
1342                action.trigger_mode = EventTriggerMode.RAW
1343            else:
1344                trig_val = self._trigger_var.get()
1345                if not trig_val.endswith(_SPLINE_ADV_SUFFIX):
1346                    action.trigger_mode = EventTriggerMode(trig_val)
1347            if action.input_type == InputType.BOOLEAN_TRIGGER:
1348                action.threshold = float(
1349                    self._threshold_var.get() or 0.5)
1350            # RAW mode bypasses all shaping — reset to defaults so
1351            # the saved YAML doesn't contain misleading values.
1352            if action.trigger_mode == EventTriggerMode.RAW:
1353                action.deadband = 0.0
1354                action.inversion = False
1355                action.scale = 1.0
1356                action.slew_rate = 0.0
1357                action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1358            else:
1359                action.deadband = float(
1360                    self._deadband_var.get() or 0)
1361                action.inversion = self._inversion_var.get()
1362                action.scale = float(self._scale_var.get() or 1.0)
1363                action.slew_rate = float(
1364                    self._slew_var.get() or 0.0)
1365                if self._neg_slew_enable_var.get():
1366                    val = float(self._neg_slew_var.get() or 0.0)
1367                    action.extra[EXTRA_NEGATIVE_SLEW_RATE] = min(
1368                        val, 0.0)
1369                else:
1370                    action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1371            # Virtual Analog extra fields
1372            if action.input_type == InputType.VIRTUAL_ANALOG:
1373                action.extra[EXTRA_VA_BUTTON_MODE] = (
1374                    self._va_mode_var.get() or "held")
1375                action.extra[EXTRA_VA_RAMP_RATE] = float(
1376                    self._va_ramp_var.get() or 0.0)
1377                action.extra[EXTRA_VA_ACCELERATION] = float(
1378                    self._va_accel_var.get() or 0.0)
1379                action.extra[EXTRA_VA_TARGET_VALUE] = float(
1380                    self._va_target_var.get() or 1.0)
1381                action.extra[EXTRA_VA_REST_VALUE] = float(
1382                    self._va_rest_var.get() or 0.0)
1383                action.extra[EXTRA_VA_ZERO_VEL_ON_RELEASE] = bool(
1384                    self._va_zero_vel_var.get())
1385                if self._va_neg_ramp_enable_var.get():
1386                    action.extra[EXTRA_VA_NEGATIVE_RAMP_RATE] = float(
1387                        self._va_neg_ramp_var.get() or 0.0)
1388                else:
1389                    action.extra.pop(EXTRA_VA_NEGATIVE_RAMP_RATE, None)
1390                if self._va_neg_accel_enable_var.get():
1391                    action.extra[EXTRA_VA_NEGATIVE_ACCELERATION] = float(
1392                        self._va_neg_accel_var.get() or 0.0)
1393                else:
1394                    action.extra.pop(EXTRA_VA_NEGATIVE_ACCELERATION, None)
1395        except (ValueError, KeyError):
1396            return False
1397
1398        return True
1399
1400    def _on_desc_modified(self, event=None):
1401        """Handle description Text widget changes."""
1402        if not self._desc_text.edit_modified():
1403            return
1404        self._desc_text.edit_modified(False)
1405        if self._updating_form:
1406            return
1407        self._on_field_changed()
1408
1409    def _on_field_changed(self, *args):
1410        """Handle changes to detail fields (not name or group)."""
1411        if self._updating_form:
1412            return
1413        if self._on_before_change:
1414            self._on_before_change(500)
1415        self._update_type_visibility()
1416        if self._save_detail() and self._on_actions_changed:
1417            # Mark as user-customized (unless this is an auto-set
1418            # from a type switch — that resets the flag after)
1419            if not self._type_switch_active and self._selected_name:
1420                action = self._actions.get(self._selected_name)
1421                if action:
1422                    action._has_custom = True
1423            self._on_actions_changed()
1424
1425    def _on_neg_slew_toggled(self, *args):
1426        """Enable/disable the negative slew rate spinbox."""
1427        enabled = self._neg_slew_enable_var.get()
1428        self._neg_slew_spin.config(state="normal" if enabled else "disabled")
1429        if not self._updating_form:
1430            self._on_field_changed()
1431
1432    def _on_va_neg_ramp_toggled(self, *args):
1433        """Enable/disable the VA negative ramp rate spinbox."""
1434        enabled = self._va_neg_ramp_enable_var.get()
1435        self._va_neg_ramp_spin.config(
1436            state="normal" if enabled else "disabled")
1437        if not self._updating_form:
1438            self._on_field_changed()
1439
1440    def _on_va_neg_accel_toggled(self, *args):
1441        """Enable/disable the VA negative acceleration spinbox."""
1442        enabled = self._va_neg_accel_enable_var.get()
1443        self._va_neg_accel_spin.config(
1444            state="normal" if enabled else "disabled")
1445        if not self._updating_form:
1446            self._on_field_changed()
1447
1448    def _on_input_type_changed(self, *args):
1449        """Handle input type dropdown change."""
1450        if not self._updating_form:
1451            try:
1452                input_type = InputType(self._input_type_var.get())
1453            except ValueError:
1454                input_type = InputType.BUTTON
1455
1456            # Warn on any input type change if user-customized settings exist
1457            if self._selected_name:
1458                action = self._actions.get(self._selected_name)
1459                if (action and input_type != action.input_type
1460                        and getattr(action, '_has_custom', False)):
1461                    if not messagebox.askyesno(
1462                        "Change Input Type",
1463                        "Changing input type may reset or\n"
1464                        "invalidate current settings (deadband,\n"
1465                        "scale, curves, bindings). Continue?",
1466                    ):
1467                        self._updating_form = True
1468                        try:
1469                            self._input_type_var.set(
1470                                action.input_type.value)
1471                        finally:
1472                            self._updating_form = False
1473                        return
1474
1475            # Suppress intermediate traces from deadband/trigger var changes
1476            # so the final _on_field_changed captures a single pre-mutation snapshot
1477            self._updating_form = True
1478            try:
1479                if input_type == InputType.ANALOG:
1480                    # Default deadband to 5% when switching to analog
1481                    try:
1482                        current_db = float(self._deadband_var.get() or 0)
1483                    except ValueError:
1484                        current_db = 0.0
1485                    if current_db == 0.0:
1486                        self._deadband_var.set("0.05")
1487                elif input_type == InputType.VIRTUAL_ANALOG:
1488                    # VA defaults: no deadband (no physical axis)
1489                    self._deadband_var.set("0.0")
1490                    self._va_ramp_var.set("1.0")
1491                    self._va_accel_var.set("0.0")
1492                    self._va_target_var.set("1.0")
1493                    self._va_rest_var.set("0.0")
1494                    self._va_zero_vel_var.set(False)
1495                    self._va_neg_ramp_enable_var.set(False)
1496                    self._va_neg_ramp_var.set("0.0")
1497                    self._va_neg_accel_enable_var.set(False)
1498                    self._va_neg_accel_var.set("0.0")
1499                else:
1500                    # Reset analog-specific fields to defaults
1501                    self._deadband_var.set("0.0")
1502                    self._inversion_var.set(False)
1503                    self._scale_var.set("1.0")
1504                    self._slew_var.set("0.0")
1505                    self._neg_slew_enable_var.set(False)
1506                    self._neg_slew_var.set("0.0")
1507                    action = self._actions.get(self._selected_name)
1508                    if action:
1509                        action.extra.pop(EXTRA_SPLINE_POINTS, None)
1510                        action.extra.pop(EXTRA_SEGMENT_POINTS, None)
1511                        action.extra.pop(EXTRA_NEGATIVE_SLEW_RATE, None)
1512                # Clean up VA extra keys when switching away from VA
1513                if input_type != InputType.VIRTUAL_ANALOG:
1514                    action = self._actions.get(self._selected_name)
1515                    if action:
1516                        for key in (EXTRA_VA_BUTTON_MODE,
1517                                    EXTRA_VA_RAMP_RATE,
1518                                    EXTRA_VA_ACCELERATION,
1519                                    EXTRA_VA_NEGATIVE_RAMP_RATE,
1520                                    EXTRA_VA_NEGATIVE_ACCELERATION,
1521                                    EXTRA_VA_ZERO_VEL_ON_RELEASE,
1522                                    EXTRA_VA_TARGET_VALUE,
1523                                    EXTRA_VA_REST_VALUE):
1524                            action.extra.pop(key, None)
1525                # Update trigger mode options and default
1526                self._update_trigger_mode_options(input_type)
1527            finally:
1528                self._updating_form = False
1529
1530            # Save fields and update visibility, but suppress the
1531            # _has_custom flag — type switches reset it to False
1532            self._type_switch_active = True
1533            self._update_type_visibility()
1534            self._on_field_changed()
1535            self._type_switch_active = False
1536            action = self._actions.get(self._selected_name)
1537            if action:
1538                action._has_custom = False
1539
1540    def _on_edit_spline(self):
1541        """Open the spline editor dialog for the selected action."""
1542        if self._selected_name is None:
1543            return
1544        action = self._actions.get(self._selected_name)
1545        if not action:
1546            return
1547
1548        from host.controller_config.spline_editor import (
1549            SplineEditorDialog, default_points,
1550        )
1551
1552        points = action.extra.get(EXTRA_SPLINE_POINTS)
1553        if not points:
1554            points = default_points()
1555
1556        # Collect spline curves from other actions for "Copy from..."
1557        other_curves = {}
1558        for qname, act in self._actions.items():
1559            if qname != self._selected_name:
1560                pts = act.extra.get(EXTRA_SPLINE_POINTS)
1561                if pts:
1562                    other_curves[qname] = pts
1563
1564        dialog = SplineEditorDialog(self.winfo_toplevel(), points,
1565                                    other_curves,
1566                                    scale=action.scale,
1567                                    inversion=action.inversion)
1568        result = dialog.get_result()
1569
1570        if result is not None:
1571            if self._on_before_change:
1572                self._on_before_change(0)
1573            action.extra[EXTRA_SPLINE_POINTS] = result
1574            if self._on_actions_changed:
1575                self._on_actions_changed()
1576
1577    def _on_edit_segments(self):
1578        """Open the segment editor dialog for the selected action."""
1579        if self._selected_name is None:
1580            return
1581        action = self._actions.get(self._selected_name)
1582        if not action:
1583            return
1584
1585        from host.controller_config.segment_editor import (
1586            SegmentEditorDialog, default_segment_points,
1587        )
1588
1589        points = action.extra.get(EXTRA_SEGMENT_POINTS)
1590        if not points:
1591            points = default_segment_points()
1592
1593        # Collect segment curves from other actions for "Copy from..."
1594        other_curves = {}
1595        for qname, act in self._actions.items():
1596            if qname != self._selected_name:
1597                pts = act.extra.get(EXTRA_SEGMENT_POINTS)
1598                if pts:
1599                    other_curves[qname] = pts
1600
1601        flags = self._get_advanced_flags()
1602        dialog = SegmentEditorDialog(self.winfo_toplevel(), points,
1603                                     other_curves,
1604                                     scale=action.scale,
1605                                     inversion=action.inversion,
1606                                     allow_nonmono=flags["nonmono"])
1607        result = dialog.get_result()
1608
1609        if result is not None:
1610            if self._on_before_change:
1611                self._on_before_change(0)
1612            action.extra[EXTRA_SEGMENT_POINTS] = result
1613            if self._on_actions_changed:
1614                self._on_actions_changed()
1615
1616    def _set_field_error(self, widget, has_error: bool):
1617        """Apply or clear error styling on a ttk Entry or Combobox."""
1618        base = widget.winfo_class()  # "TEntry" or "TCombobox"
1619        widget.configure(style=f"Error.{base}" if has_error else f"{base}")
1620
1621    def _flash_field_warning(self, widget, err: str):
1622        """Flash error styling and show a warning for an invalid field."""
1623        if getattr(self, '_showing_warning', False):
1624            return
1625        self._showing_warning = True
1626        self._set_field_error(widget, True)
1627        messagebox.showwarning("Invalid Value", err)
1628        self._set_field_error(widget, False)
1629        self._showing_warning = False
1630
1631    def _on_name_changed(self, *args):
1632        """Trace callback — deferred to commit on focus-out / Enter."""
1633        # Only update the tree label in real-time; actual rename is deferred
1634        pass
1635
1636    def _commit_name(self, *args):
1637        """Commit name change on focus-out or Enter key."""
1638        if self._updating_form or self._selected_name is None:
1639            return
1640
1641        new_name = self._name_var.get().strip()
1642        old_qname = self._selected_name
1643        action = self._actions.get(old_qname)
1644        if not action:
1645            return
1646
1647        # No change — just clear any error styling
1648        if not new_name or new_name == action.name:
1649            self._set_field_error(self._name_entry, False)
1650            return
1651
1652        err = validate_action_name(new_name)
1653        if not err:
1654            new_qname = f"{action.group}.{new_name}"
1655            err = validate_action_rename(
1656                old_qname, new_qname, self._actions)
1657
1658        if err:
1659            self._flash_field_warning(self._name_entry, err)
1660            # Revert to the current valid name
1661            self._updating_form = True
1662            self._name_var.set(action.name)
1663            self._updating_form = False
1664            return
1665
1666        self._set_field_error(self._name_entry, False)
1667        if self._on_before_change:
1668            self._on_before_change(500)
1669        del self._actions[old_qname]
1670        action.name = new_name
1671        self._actions[new_qname] = action
1672        self._selected_name = new_qname
1673
1674        # Update binding references from old to new qualified name
1675        if self._on_action_renamed and old_qname != new_qname:
1676            self._on_action_renamed(old_qname, new_qname)
1677
1678        self._refresh_tree()
1679        self._reselect(new_qname)
1680
1681        if self._on_actions_changed:
1682            self._on_actions_changed()
1683
1684    def rename_action(self, old_qname: str, new_qname: str):
1685        """Re-key an action in the dict and refresh the tree.
1686
1687        Called by the App when the Action Editor tab changes an action's
1688        name or group, so the sidebar tree stays in sync.
1689        Returns True if the rename succeeded, False if rejected.
1690        """
1691        action = self._actions.get(old_qname)
1692        if not action or old_qname == new_qname:
1693            return False
1694        err = validate_action_rename(old_qname, new_qname, self._actions)
1695        if err:
1696            messagebox.showwarning("Invalid Name", err)
1697            return False
1698
1699        del self._actions[old_qname]
1700        self._actions[new_qname] = action
1701        self._selected_name = new_qname
1702
1703        if self._on_action_renamed:
1704            self._on_action_renamed(old_qname, new_qname)
1705
1706        self._refresh_tree()
1707        self._reselect(new_qname)
1708        return True
1709
1710    def _on_group_changed(self, *args):
1711        """Trace callback — deferred to commit on focus-out / Enter."""
1712        pass
1713
1714    def _commit_group(self, *args):
1715        """Commit group change on focus-out or Enter key."""
1716        if self._updating_form or self._selected_name is None:
1717            return
1718
1719        new_group = self._group_var.get().strip()
1720        action = self._actions.get(self._selected_name)
1721        if not action:
1722            return
1723
1724        if not new_group:
1725            self._flash_field_warning(
1726                self._group_combo, "Group cannot be empty.")
1727            self._updating_form = True
1728            self._group_var.set(action.group)
1729            self._updating_form = False
1730            return
1731
1732        if new_group == action.group:
1733            self._set_field_error(self._group_combo, False)
1734            return
1735
1736        self._set_field_error(self._group_combo, False)
1737        self._move_action_to_group(self._selected_name, new_group)
1738
1739    def _move_action_to_group(self, qname: str, new_group: str):
1740        """Move an action to a different group, rejecting on collision."""
1741        action = self._actions.get(qname)
1742        if not action or new_group == action.group:
1743            return
1744
1745        new_qname = f"{new_group}.{action.name}"
1746        if new_qname in self._actions:
1747            self._flash_field_warning(
1748                self._group_combo,
1749                f"An action named '{new_qname}' already exists.\n"
1750                "Rename the action first, or choose a different group.")
1751            # Revert the group field
1752            self._updating_form = True
1753            self._group_var.set(action.group)
1754            self._updating_form = False
1755            return
1756
1757        if self._on_before_change:
1758            self._on_before_change(0)
1759
1760        old_qname = qname
1761        old_group = action.group
1762        del self._actions[qname]
1763
1764        action.group = new_group
1765        self._actions[new_qname] = action
1766        self._selected_name = new_qname
1767
1768        # Preserve old group as empty if it has no remaining actions
1769        if not any(a.group == old_group for a in self._actions.values()):
1770            self._empty_groups.add(old_group)
1771
1772        # Remove new group from empty tracking since it now has actions
1773        self._empty_groups.discard(new_group)
1774
1775        # Update binding references from old to new qualified name
1776        if self._on_action_renamed and old_qname != new_qname:
1777            self._on_action_renamed(old_qname, new_qname)
1778
1779        self._refresh_tree()
1780        self._reselect(new_qname)
1781        self._load_detail(new_qname)
1782
1783        if self._on_actions_changed:
1784            self._on_actions_changed()
1785
1786    # ------------------------------------------------------------------
1787    # Action CRUD
1788    # ------------------------------------------------------------------
1789
1790    def _add_action(self):
1791        """Add a new action in the currently selected group, or 'general'."""
1792        if self._on_before_change:
1793            self._on_before_change(0)
1794        group = self._get_selected_group()
1795
1796        base = "new_action"
1797        name = base
1798        i = 1
1799        while f"{group}.{name}" in self._actions:
1800            name = f"{base}_{i}"
1801            i += 1
1802
1803        qname = f"{group}.{name}"
1804        action = ActionDefinition(name=name, group=group)
1805        action._has_custom = False
1806        self._actions[qname] = action
1807        self._empty_groups.discard(group)
1808        self._refresh_tree()
1809        self._reselect(qname)
1810
1811        self._selected_name = qname
1812        self._load_detail(qname)
1813        self._set_detail_enabled(True)
1814
1815        self._name_entry.focus_set()
1816        self._name_entry.select_range(0, tk.END)
1817
1818        if self._on_actions_changed:
1819            self._on_actions_changed()
1820
1821    def _remove_action(self):
1822        """Remove the selected action."""
1823        if self._selected_name is None:
1824            return
1825
1826        name = self._selected_name
1827        action = self._actions.get(name)
1828        display = action.qualified_name if action else name
1829        if not messagebox.askyesno("Remove Action",
1830                                   f"Remove action '{display}'?"):
1831            return
1832
1833        if self._on_before_change:
1834            self._on_before_change(0)
1835        del self._actions[name]
1836        self._selected_name = None
1837        self._refresh_tree()
1838        self._set_detail_enabled(False)
1839
1840        if self._on_actions_changed:
1841            self._on_actions_changed()
1842
1843    def _duplicate_action(self):
1844        """Duplicate the selected action with a new name."""
1845        if self._selected_name is None:
1846            return
1847
1848        if self._on_before_change:
1849            self._on_before_change(0)
1850        src = self._actions[self._selected_name]
1851        base = f"{src.name}_copy"
1852        name = base
1853        i = 1
1854        while f"{src.group}.{name}" in self._actions:
1855            name = f"{base}_{i}"
1856            i += 1
1857
1858        new_action = ActionDefinition(
1859            name=name,
1860            description=src.description,
1861            group=src.group,
1862            input_type=src.input_type,
1863            trigger_mode=src.trigger_mode,
1864            deadband=src.deadband,
1865            inversion=src.inversion,
1866            scale=src.scale,
1867            extra=dict(src.extra),
1868        )
1869        qname = new_action.qualified_name
1870        self._actions[qname] = new_action
1871        self._refresh_tree()
1872        self._reselect(qname)
1873
1874        self._selected_name = qname
1875        self._load_detail(qname)
1876        self._set_detail_enabled(True)
1877
1878        if self._on_actions_changed:
1879            self._on_actions_changed()
1880
1881    # ------------------------------------------------------------------
1882    # Group Management
1883    # ------------------------------------------------------------------
1884
1885    def _add_group(self):
1886        """Prompt for a new group name and create an empty group node."""
1887        name = simpledialog.askstring("New Group", "Group name:", parent=self)
1888        if not name or not name.strip():
1889            return
1890        name = name.strip().lower().replace(" ", "_")
1891
1892        # Check if group already exists
1893        groups = self._collect_groups()
1894        if name in groups:
1895            messagebox.showinfo("Group Exists",
1896                                f"Group '{name}' already exists.")
1897            return
1898
1899        self._empty_groups.add(name)
1900        self._refresh_tree()
1901
1902    def _remove_group(self):
1903        """Remove the selected group and all its actions."""
1904        group = self._get_selected_group_name()
1905        if not group:
1906            messagebox.showinfo("No Group Selected",
1907                                "Select a group to remove.")
1908            return
1909
1910        group_actions = [qn for qn, a in self._actions.items()
1911                         if a.group == group]
1912
1913        if group_actions:
1914            if not messagebox.askyesno(
1915                "Remove Group",
1916                f"Remove group '{group}' and its "
1917                f"{len(group_actions)} action(s)?",
1918            ):
1919                return
1920            if self._on_before_change:
1921                self._on_before_change(0)
1922            for qn in group_actions:
1923                del self._actions[qn]
1924        else:
1925            if self._on_before_change:
1926                self._on_before_change(0)
1927            self._empty_groups.discard(group)
1928
1929        self._selected_name = None
1930        self._refresh_tree()
1931        self._set_detail_enabled(False)
1932
1933        if self._on_actions_changed:
1934            self._on_actions_changed()
1935
1936    def _rename_group(self):
1937        """Rename the selected group and update all its actions."""
1938        old_name = self._get_selected_group_name()
1939        if not old_name:
1940            messagebox.showinfo("No Group Selected",
1941                                "Select a group to rename.")
1942            return
1943
1944        new_name = simpledialog.askstring(
1945            "Rename Group", "New name:", initialvalue=old_name, parent=self)
1946        if not new_name or not new_name.strip():
1947            return
1948        new_name = new_name.strip().lower().replace(" ", "_")
1949
1950        if new_name == old_name:
1951            return
1952
1953        # Validate: no dots allowed in group names
1954        if "." in new_name:
1955            messagebox.showerror("Invalid Name",
1956                                 "Group names cannot contain dots.")
1957            return
1958
1959        # Check for collision with existing groups
1960        groups = self._collect_groups()
1961        if new_name in groups:
1962            messagebox.showerror("Group Exists",
1963                                 f"Group '{new_name}' already exists.")
1964            return
1965
1966        if self._on_before_change:
1967            self._on_before_change(0)
1968
1969        # Collect actions in this group
1970        group_actions = [(qn, a) for qn, a in list(self._actions.items())
1971                         if a.group == old_name]
1972
1973        # Re-key each action with the new group
1974        for old_qname, action in group_actions:
1975            del self._actions[old_qname]
1976            action.group = new_name
1977            new_qname = action.qualified_name
1978            self._actions[new_qname] = action
1979
1980            if self._on_action_renamed:
1981                self._on_action_renamed(old_qname, new_qname)
1982
1983        # Update empty groups tracking
1984        if old_name in self._empty_groups:
1985            self._empty_groups.discard(old_name)
1986            self._empty_groups.add(new_name)
1987
1988        # Update selection to the renamed group
1989        if self._selected_name:
1990            # If the selected action was in this group, update reference
1991            for old_qname, action in group_actions:
1992                if self._selected_name == old_qname:
1993                    self._selected_name = action.qualified_name
1994                    break
1995
1996        self._refresh_tree()
1997
1998        if self._on_actions_changed:
1999            self._on_actions_changed()
2000
2001    # ------------------------------------------------------------------
2002    # Context Menu (Right-Click)
2003    # ------------------------------------------------------------------
2004
2005    def _on_assign_button(self):
2006        """Open the assign context menu from the Assign button."""
2007        if not self._selected_name:
2008            return
2009        if self._selected_name not in self._actions:
2010            return
2011        # Position menu at the button
2012        btn = self._assign_btn
2013        x = btn.winfo_rootx()
2014        y = btn.winfo_rooty() + btn.winfo_height()
2015
2016        class _FakeEvent:
2017            pass
2018
2019        evt = _FakeEvent()
2020        evt.x_root = x
2021        evt.y_root = y
2022        self._show_action_context_menu(evt, self._selected_name)
2023
2024    def _on_right_click(self, event):
2025        """Show context menu on right-click."""
2026        item = self._tree.identify_row(event.y)
2027        if not item:
2028            return
2029        if item.startswith(self._GROUP_PREFIX):
2030            self._tree.selection_set(item)
2031            self._context_menu.post(event.x_root, event.y_root)
2032        elif item in self._actions:
2033            self._tree.selection_set(item)
2034            self._show_action_context_menu(event, item)
2035
2036    def _show_action_context_menu(self, event, qname: str):
2037        """Build and show a context menu for an action item.
2038
2039        Shows each controller as a submenu with its compatible inputs.
2040        Bound inputs have a checkmark and clicking unassigns them.
2041        Unbound inputs are plain and clicking assigns them.
2042        """
2043        if not (self._get_all_controllers and self._get_compatible_inputs):
2044            return
2045
2046        controllers = self._get_all_controllers()
2047        compatible = self._get_compatible_inputs(qname)
2048
2049        menu = tk.Menu(self, tearoff=0)
2050        has_any_binding = False
2051
2052        for port, ctrl_name in controllers:
2053            sub = tk.Menu(menu, tearoff=0)
2054            has_bound = False
2055            has_unbound = False
2056            bound_items = []
2057            unbound_items = []
2058
2059            for input_name, display_name in compatible:
2060                # Check if this action is bound to this input on this port
2061                is_bound = self._is_action_bound(
2062                    qname, port, input_name)
2063                if is_bound:
2064                    has_bound = True
2065                    has_any_binding = True
2066                    bound_items.append((input_name, display_name))
2067                else:
2068                    has_unbound = True
2069                    unbound_items.append((input_name, display_name))
2070
2071            # Add bound inputs first (with checkmark)
2072            for input_name, display_name in bound_items:
2073                sub.add_command(
2074                    label=f"\u2713 {display_name}",
2075                    command=lambda q=qname, p=port, n=input_name:
2076                        self._on_unassign_action(q, p, n)
2077                        if self._on_unassign_action else None,
2078                )
2079
2080            # Separator between bound and unbound
2081            if has_bound and has_unbound:
2082                sub.add_separator()
2083
2084            # Add unbound compatible inputs
2085            for input_name, display_name in unbound_items:
2086                sub.add_command(
2087                    label=display_name,
2088                    command=lambda q=qname, p=port, n=input_name:
2089                        self._on_assign_action(q, p, n)
2090                        if self._on_assign_action else None,
2091                )
2092
2093            label = f"{ctrl_name} (Port {port})"
2094            menu.add_cascade(label=label, menu=sub)
2095
2096        menu.add_separator()
2097        menu.add_command(
2098            label="Remove from All Inputs",
2099            command=lambda q=qname:
2100                self._on_unassign_all(q)
2101                if self._on_unassign_all else None,
2102            state=tk.NORMAL if has_any_binding else tk.DISABLED,
2103        )
2104
2105        menu.tk_popup(event.x_root, event.y_root)
2106
2107    def _is_action_bound(self, qname: str, port: int,
2108                         input_name: str) -> bool:
2109        """Check if an action is bound to a specific input on a port."""
2110        if self._is_action_bound_cb:
2111            return self._is_action_bound_cb(qname, port, input_name)
2112        return False
2113
2114    def _on_context_export_group(self):
2115        """Handle 'Export Group...' from context menu."""
2116        group = self._get_selected_group_name()
2117        if group and self._on_export_group:
2118            self._on_export_group(group)
2119
2120    # ------------------------------------------------------------------
2121    # Drag-from-Tree (cross-widget drag-and-drop)
2122    # ------------------------------------------------------------------
2123
2124    def _on_tree_scroll(self, event):
2125        """Cancel any drag (pending or active) when the user scrolls.
2126
2127        Scrolling shifts items under the cursor, so a release after
2128        scrolling could target the wrong group.
2129        """
2130        if self._drag_item:
2131            if self._drag_started and self._on_drag_end:
2132                self._on_drag_end()
2133            self._drag_item = None
2134            self._drag_started = False
2135            self._drag_target_group = None
2136            self._clear_drag_highlight()
2137
2138    def _on_tree_press(self, event):
2139        """Record potential drag start position."""
2140        item = self._tree.identify_row(event.y)
2141        if item and not item.startswith(self._GROUP_PREFIX) and item in self._actions:
2142            self._drag_item = item
2143            self._drag_start_pos = (event.x_root, event.y_root)
2144            self._drag_started = False
2145        else:
2146            self._drag_item = None
2147
2148    def _on_tree_drag(self, event):
2149        """Start drag after movement exceeds threshold."""
2150        if not self._drag_item:
2151            return
2152
2153        if not self._drag_started:
2154            dx = event.x_root - self._drag_start_pos[0]
2155            dy = event.y_root - self._drag_start_pos[1]
2156            if (dx * dx + dy * dy) < self._DRAG_THRESHOLD ** 2:
2157                return
2158            self._drag_started = True
2159            if self._on_drag_start:
2160                self._on_drag_start(self._drag_item)
2161
2162        # Track intra-tree group target for visual feedback
2163        # Only consider intra-tree drops when mouse is inside the tree widget;
2164        # implicit grab delivers events even when the mouse is over other widgets
2165        if self._is_over_tree(event):
2166            item = self._tree.identify_row(event.y)
2167            target_group = self._resolve_group_for_item(item)
2168        else:
2169            target_group = None
2170
2171        action = self._actions.get(self._drag_item)
2172        source_group = action.group if action else None
2173
2174        if target_group and target_group != source_group:
2175            group_iid = f"{self._GROUP_PREFIX}{target_group}"
2176            self._set_drag_highlight(group_iid)
2177        else:
2178            self._clear_drag_highlight()
2179        self._drag_target_group = target_group
2180
2181    def _on_tree_release(self, event):
2182        """Handle release — move action to target group or let app handle."""
2183        drag_item = self._drag_item
2184        was_dragging = self._drag_started
2185
2186        # Reset all local drag state
2187        self._drag_item = None
2188        self._drag_started = False
2189        self._drag_target_group = None
2190        self._clear_drag_highlight()
2191
2192        if not was_dragging or not drag_item:
2193            return
2194
2195        # Check if released over a group in the tree (not outside the widget)
2196        if not self._is_over_tree(event):
2197            return
2198
2199        item = self._tree.identify_row(event.y)
2200        target_group = self._resolve_group_for_item(item)
2201
2202        action = self._actions.get(drag_item)
2203        if action and target_group and target_group != action.group:
2204            # Cancel cross-widget drag before moving
2205            if self._on_drag_end:
2206                self._on_drag_end()
2207            self._move_action_to_group(drag_item, target_group)
2208
2209    def _set_drag_highlight(self, group_iid: str):
2210        """Highlight a group node as a drop target."""
2211        if group_iid == self._drag_highlight_iid:
2212            return
2213        self._clear_drag_highlight()
2214        if group_iid and self._tree.exists(group_iid):
2215            self._tree.tag_configure(
2216                "drop_target",
2217                background="#cce5ff",
2218                font=("TkDefaultFont", 9, "bold"))
2219            self._tree.item(group_iid, tags=("group", "drop_target"))
2220            self._drag_highlight_iid = group_iid
2221
2222    def _clear_drag_highlight(self):
2223        """Remove drop target highlight."""
2224        if self._drag_highlight_iid and self._tree.exists(
2225                self._drag_highlight_iid):
2226            self._tree.item(self._drag_highlight_iid, tags=("group",))
2227        self._drag_highlight_iid = None
2228
2229    # ------------------------------------------------------------------
2230    # Helpers
2231
2232    def _is_over_tree(self, event) -> bool:
2233        """Check if event coordinates are within the tree widget bounds."""
2234        return (0 <= event.x <= self._tree.winfo_width()
2235                and 0 <= event.y <= self._tree.winfo_height())
2236
2237    def _resolve_group_for_item(self, item: str | None) -> str | None:
2238        """Return the group name for a tree item, or None."""
2239        if not item:
2240            return None
2241        if item.startswith(self._GROUP_PREFIX):
2242            return item[len(self._GROUP_PREFIX):]
2243        if item.startswith(self._EMPTY_PREFIX):
2244            return item[len(self._EMPTY_PREFIX):]
2245        if item in self._actions:
2246            return self._actions[item].group
2247        return None
2248    # ------------------------------------------------------------------
2249
2250    def _get_selected_group(self) -> str:
2251        """Return the group for the current selection, defaulting to 'general'."""
2252        sel = self._tree.selection()
2253        if sel:
2254            item_id = sel[0]
2255            if item_id.startswith(self._GROUP_PREFIX):
2256                return item_id[len(self._GROUP_PREFIX):]
2257            if item_id in self._actions:
2258                return self._actions[item_id].group
2259        return DEFAULT_GROUP
2260
2261    def _get_selected_group_name(self) -> str | None:
2262        """Return the group name if a group node is selected, else None."""
2263        sel = self._tree.selection()
2264        if sel:
2265            item_id = sel[0]
2266            if item_id.startswith(self._GROUP_PREFIX):
2267                return item_id[len(self._GROUP_PREFIX):]
2268            # Also allow removing a group when an action in it is selected
2269            if item_id in self._actions:
2270                return self._actions[item_id].group
2271        return None
2272
2273    def _get_tooltip_text(self, item_id: str) -> str | None:
2274        """Return tooltip text for a tree item, or None."""
2275        if item_id.startswith(self._GROUP_PREFIX):
2276            group = item_id[len(self._GROUP_PREFIX):]
2277            group_actions = [a for a in self._actions.values()
2278                             if a.group == group]
2279            count = len(group_actions)
2280            lines = [f"{group} ({count} action{'s' if count != 1 else ''})"]
2281
2282            if self._get_binding_info and group_actions:
2283                unassigned = 0
2284                multi_bound = 0
2285                for a in group_actions:
2286                    bindings = self._get_binding_info(a.qualified_name)
2287                    if not bindings:
2288                        unassigned += 1
2289                    elif len(bindings) > 1:
2290                        multi_bound += 1
2291                if unassigned:
2292                    lines.append(
2293                        f"{unassigned} unassigned")
2294                if multi_bound:
2295                    lines.append(
2296                        f"{multi_bound} bound to multiple inputs")
2297
2298            return "\n".join(lines)
2299
2300        action = self._actions.get(item_id)
2301        if not action:
2302            return None
2303
2304        lines = [action.qualified_name]
2305        if action.description:
2306            lines.append(action.description)
2307
2308        # Show binding assignments
2309        if self._get_binding_info:
2310            bindings = self._get_binding_info(item_id)
2311            if bindings:
2312                lines.append("")
2313                lines.append("Assigned to:")
2314                for binding in bindings:
2315                    ctrl_name, input_display = binding[0], binding[1]
2316                    lines.append(f"  {ctrl_name} > {input_display}")
2317                if len(bindings) > 1:
2318                    lines.append("")
2319                    lines.append("[Yellow: bound to multiple inputs]")
2320            else:
2321                lines.append("")
2322                lines.append("Not assigned to any input")
2323                lines.append("[Red: unassigned]")
2324
2325        return "\n".join(lines)
2326
2327    def _reselect(self, qname: str):
2328        """Select and scroll to an item in the tree."""
2329        if self._tree.exists(qname):
2330            self._tree.selection_set(qname)
2331            self._tree.see(qname)

Panel for managing grouped action definitions.

ActionPanel( parent, on_actions_changed=None, on_export_group=None, on_drag_start=None, on_drag_end=None, on_before_change=None, get_binding_info=None, on_assign_action=None, on_unassign_action=None, on_unassign_all=None, get_all_controllers=None, get_compatible_inputs=None, is_action_bound=None, on_action_renamed=None, on_selection_changed=None, get_advanced_flags=None, icon_loader=None)
171    def __init__(self, parent, on_actions_changed=None, on_export_group=None,
172                 on_drag_start=None, on_drag_end=None,
173                 on_before_change=None, get_binding_info=None,
174                 on_assign_action=None, on_unassign_action=None,
175                 on_unassign_all=None, get_all_controllers=None,
176                 get_compatible_inputs=None, is_action_bound=None,
177                 on_action_renamed=None,
178                 on_selection_changed=None,
179                 get_advanced_flags=None,
180                 icon_loader=None):
181        """
182        Args:
183            parent: tkinter parent widget
184            on_actions_changed: callback() when any action is added/removed/modified
185            on_export_group: callback(group_name) when user requests group export
186            on_drag_start: callback(qname) when an action drag begins
187            on_drag_end: callback() when a drag ends (release)
188            on_before_change: callback(coalesce_ms) called BEFORE any mutation,
189                giving the app a chance to snapshot state for undo
190            get_binding_info: callback(qname) -> list[(ctrl_name, input_display)]
191                returns where an action is bound, or empty list if unbound
192            on_assign_action: callback(qname, port, input_name) to bind action
193            on_unassign_action: callback(qname, port, input_name) to unbind action
194            on_unassign_all: callback(qname) to remove action from all inputs
195            get_all_controllers: callback() -> list[(port, ctrl_name)]
196            get_compatible_inputs: callback(qname) ->
197                list[(input_name, display_name)] of compatible inputs
198            is_action_bound: callback(qname, port, input_name) -> bool
199            on_action_renamed: callback(old_qname, new_qname) when an action's
200                qualified name changes (group or name change) so bindings can
201                be updated
202            on_selection_changed: callback(qname | None) when tree selection
203                changes, allowing external listeners to sync
204        """
205        super().__init__(parent, padx=5, pady=5)
206        self._on_actions_changed = on_actions_changed
207        self._on_export_group = on_export_group
208        self._on_before_change = on_before_change
209        self._on_drag_start = on_drag_start
210        self._on_drag_end = on_drag_end
211        self._get_binding_info = get_binding_info
212        self._on_assign_action = on_assign_action
213        self._on_unassign_action = on_unassign_action
214        self._on_unassign_all = on_unassign_all
215        self._get_all_controllers = get_all_controllers
216        self._get_compatible_inputs = get_compatible_inputs
217        self._is_action_bound_cb = is_action_bound
218        self._on_action_renamed = on_action_renamed
219        self._on_selection_changed = on_selection_changed
220        self._get_advanced_flags = get_advanced_flags or (
221            lambda: {"splines": True, "nonmono": True})
222        self._icon_loader = icon_loader
223        self._tree_icons: list = []  # Prevent GC of PhotoImage refs
224        self._details_editable = True
225        self._actions: dict[str, ActionDefinition] = {}
226        self._empty_groups: set[str] = set()
227        self._selected_name: str | None = None
228        self._updating_form = False  # Guard against feedback loops
229        self._type_switch_active = False  # True during type-change auto-sets
230
231        # Drag-from-tree state
232        self._drag_item: str | None = None
233        self._drag_start_pos: tuple[int, int] = (0, 0)
234        self._drag_started: bool = False
235        self._drag_target_group: str | None = None
236        self._drag_highlight_iid: str | None = None
237
238        self._build_ui()

Args: parent: tkinter parent widget on_actions_changed: callback() when any action is added/removed/modified on_export_group: callback(group_name) when user requests group export on_drag_start: callback(qname) when an action drag begins on_drag_end: callback() when a drag ends (release) on_before_change: callback(coalesce_ms) called BEFORE any mutation, giving the app a chance to snapshot state for undo get_binding_info: callback(qname) -> list[(ctrl_name, input_display)] returns where an action is bound, or empty list if unbound on_assign_action: callback(qname, port, input_name) to bind action on_unassign_action: callback(qname, port, input_name) to unbind action on_unassign_all: callback(qname) to remove action from all inputs get_all_controllers: callback() -> list[(port, ctrl_name)] get_compatible_inputs: callback(qname) -> list[(input_name, display_name)] of compatible inputs is_action_bound: callback(qname, port, input_name) -> bool on_action_renamed: callback(old_qname, new_qname) when an action's qualified name changes (group or name change) so bindings can be updated on_selection_changed: callback(qname | None) when tree selection changes, allowing external listeners to sync

def set_details_editable(self, enabled: bool):
734    def set_details_editable(self, enabled: bool):
735        """Enable or disable editing of Action Details fields.
736
737        When disabled, all detail form fields become display-only.
738        """
739        self._details_editable = enabled
740        if self._selected_name:
741            self._apply_details_editable()

Enable or disable editing of Action Details fields.

When disabled, all detail form fields become display-only.

def on_advanced_changed(self):
748    def on_advanced_changed(self):
749        """Refresh UI elements affected by Advanced menu toggles."""
750        if self._selected_name:
751            action = self._actions.get(self._selected_name)
752            if action and action.input_type == InputType.ANALOG:
753                self._refresh_spline_gate()

Refresh UI elements affected by Advanced menu toggles.

def set_actions(self, actions: dict[str, utils.controller.ActionDefinition]):
755    def set_actions(self, actions: dict[str, ActionDefinition]):
756        """Load a full set of actions (e.g., from file)."""
757        self._actions = dict(actions)
758        self._tag_actions_custom()
759        self._empty_groups = set()
760        self._refresh_tree()
761        self._selected_name = None
762        self._set_detail_enabled(False)

Load a full set of actions (e.g., from file).

def get_actions(self) -> dict[str, utils.controller.ActionDefinition]:
764    def get_actions(self) -> dict[str, ActionDefinition]:
765        """Return the current actions dict keyed by qualified name."""
766        return dict(self._actions)

Return the current actions dict keyed by qualified name.

def get_action_names(self) -> list[str]:
768    def get_action_names(self) -> list[str]:
769        """Return sorted list of fully qualified action names."""
770        return sorted(self._actions.keys())

Return sorted list of fully qualified action names.

def get_empty_groups(self) -> set[str]:
772    def get_empty_groups(self) -> set[str]:
773        """Return a copy of the empty-group set (for undo snapshots)."""
774        return set(self._empty_groups)

Return a copy of the empty-group set (for undo snapshots).

def set_empty_groups(self, groups: set[str]):
776    def set_empty_groups(self, groups: set[str]):
777        """Restore the empty-group set (for undo restore)."""
778        self._empty_groups = set(groups)
779        self._refresh_tree()

Restore the empty-group set (for undo restore).

def get_group_names(self) -> list[str]:
803    def get_group_names(self) -> list[str]:
804        """Return sorted list of all group names (including empty/default).
805
806        Single source of truth for group names used by both the
807        ActionPanel and ActionEditorTab group dropdowns.
808        """
809        return self._sorted_group_names(self._collect_groups())

Return sorted list of all group names (including empty/default).

Single source of truth for group names used by both the ActionPanel and ActionEditorTab group dropdowns.

def update_binding_tags(self):
879    def update_binding_tags(self):
880        """Update action item background colors based on binding status.
881
882        Called after bindings change (drag-drop, dialog, undo, file load).
883        - Unassigned actions get a faint red background.
884        - Actions bound to more than one input get a faint yellow background.
885        - Collapsed groups reflect child status: red (unassigned), yellow
886          (duplicate-bound), or orange (both).
887        """
888        self._tree.tag_configure("unassigned",
889                                 background="#ffdddd",
890                                 font=("TkDefaultFont", 10))
891        self._tree.tag_configure("multi_bound",
892                                 background="#ffffcc",
893                                 font=("TkDefaultFont", 10))
894        self._tree.tag_configure("action",
895                                 background="",
896                                 font=("TkDefaultFont", 10))
897        # Group-level status tags (shown when collapsed)
898        self._tree.tag_configure("group_unassigned",
899                                 background="#ffdddd",
900                                 font=("TkDefaultFont", 10, "bold"))
901        self._tree.tag_configure("group_multi_bound",
902                                 background="#ffffcc",
903                                 font=("TkDefaultFont", 10, "bold"))
904        self._tree.tag_configure("group_mixed",
905                                 background="#ffddbb",
906                                 font=("TkDefaultFont", 10, "bold"))
907
908        if not self._get_binding_info:
909            return
910
911        # Track per-group status flags
912        group_has_unassigned: dict[str, bool] = {}
913        group_has_multi: dict[str, bool] = {}
914
915        # Clear old icon refs before rebuilding
916        self._tree_icons.clear()
917
918        for qname, action in self._actions.items():
919            if not self._tree.exists(qname):
920                continue
921            bindings = self._get_binding_info(qname)
922            if not bindings:
923                self._tree.item(qname, tags=("unassigned",))
924                group_has_unassigned[action.group] = True
925            elif len(bindings) > 1:
926                self._tree.item(qname, tags=("multi_bound",))
927                group_has_multi[action.group] = True
928            else:
929                self._tree.item(qname, tags=("action",))
930
931            # Set icon from first binding's input name
932            icon = None
933            if bindings and self._icon_loader:
934                input_name = bindings[0][2]  # (ctrl, display, input_name)
935                icon = self._icon_loader.get_tk_icon(input_name, 20)
936            if icon:
937                self._tree_icons.append(icon)
938                self._tree.item(qname, image=icon)
939            else:
940                self._tree.item(qname, image="")
941
942        # Apply status colors to collapsed group nodes
943        self._update_group_tags(group_has_unassigned, group_has_multi)

Update action item background colors based on binding status.

Called after bindings change (drag-drop, dialog, undo, file load).

  • Unassigned actions get a faint red background.
  • Actions bound to more than one input get a faint yellow background.
  • Collapsed groups reflect child status: red (unassigned), yellow (duplicate-bound), or orange (both).
def rename_action(self, old_qname: str, new_qname: str):
1684    def rename_action(self, old_qname: str, new_qname: str):
1685        """Re-key an action in the dict and refresh the tree.
1686
1687        Called by the App when the Action Editor tab changes an action's
1688        name or group, so the sidebar tree stays in sync.
1689        Returns True if the rename succeeded, False if rejected.
1690        """
1691        action = self._actions.get(old_qname)
1692        if not action or old_qname == new_qname:
1693            return False
1694        err = validate_action_rename(old_qname, new_qname, self._actions)
1695        if err:
1696            messagebox.showwarning("Invalid Name", err)
1697            return False
1698
1699        del self._actions[old_qname]
1700        self._actions[new_qname] = action
1701        self._selected_name = new_qname
1702
1703        if self._on_action_renamed:
1704            self._on_action_renamed(old_qname, new_qname)
1705
1706        self._refresh_tree()
1707        self._reselect(new_qname)
1708        return True

Re-key an action in the dict and refresh the tree.

Called by the App when the Action Editor tab changes an action's name or group, so the sidebar tree stays in sync. Returns True if the rename succeeded, False if rejected.