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