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