host.controller_config.app
Main application window for the controller configuration tool.
Ties together the action panel, controller canvas, and binding dialog with a menu bar and controller tabs.
1"""Main application window for the controller configuration tool. 2 3Ties together the action panel, controller canvas, and binding dialog 4with a menu bar and controller tabs. 5""" 6 7import json 8import time 9import tkinter as tk 10from copy import deepcopy 11from tkinter import ttk, filedialog, messagebox 12from pathlib import Path 13 14import sys 15 16from host.controller_config.main import _get_project_root 17from host.controller_config.icon_loader import InputIconLoader 18 19# Ensure project root is on the path so utils.controller can be imported 20_project_root = _get_project_root() 21if str(_project_root) not in sys.path: 22 sys.path.insert(0, str(_project_root)) 23 24# When frozen (PyInstaller), persist settings and look for data next to the EXE. 25# When running from source, use the normal locations. 26if getattr(sys, 'frozen', False): 27 _exe_dir = Path(sys.executable).parent 28 _default_data_dir = _exe_dir / "data" 29 _settings_file = _exe_dir / ".controller_config_settings.json" 30else: 31 _default_data_dir = _project_root / "data" 32 _settings_file = Path(__file__).resolve().parent / ".settings.json" 33 34from utils.controller.model import FullConfig, ControllerConfig, InputType 35from utils.controller.config_io import ( 36 load_actions_from_file, 37 load_config, 38 save_actions_to_file, 39 save_assignments_to_file, 40 save_config, 41) 42 43from .action_panel import ActionPanel 44from .action_editor_tab import ActionEditorTab 45from .binding_dialog import BindingDialog 46from .controller_canvas import ControllerCanvas 47from .import_dialog import ImportConflictDialog 48from .layout_coords import XBOX_INPUT_MAP 49from .print_render import export_pages 50 51 52def load_settings() -> dict: 53 """Load app settings from the settings file. 54 55 Usable without instantiating the GUI (e.g. from CLI). 56 """ 57 try: 58 if _settings_file.exists(): 59 return json.loads(_settings_file.read_text()) 60 except (json.JSONDecodeError, OSError): 61 pass # Corrupt or missing settings file; fall back to defaults 62 return {} 63 64# Maps controller input type (str from layout_coords) to compatible action 65# InputType values. POV directions use "button" type since the factory 66# converts the raw POV angle to individual booleans at runtime. 67_COMPAT_ACTION_TYPES: dict[str, set[InputType]] = { 68 "button": {InputType.BUTTON, InputType.VIRTUAL_ANALOG}, 69 "axis": {InputType.ANALOG, InputType.BOOLEAN_TRIGGER}, 70 "output": {InputType.OUTPUT}, 71} 72 73# Human-readable descriptions of what each input type accepts 74_INPUT_TYPE_DESCRIPTION: dict[str, str] = { 75 "button": "Button inputs accept Button and Virtual Analog actions", 76 "axis": "Axis inputs accept Analog and Boolean Trigger actions", 77 "output": "Output inputs accept Output actions", 78} 79 80 81class _UnsavedChangesDialog(tk.Toplevel): 82 """Modal dialog offering Save / Save As / Discard / Cancel.""" 83 84 def __init__(self, parent): 85 super().__init__(parent) 86 self.title("Unsaved Changes") 87 self.resizable(False, False) 88 self.result: str = "cancel" 89 90 self.transient(parent) 91 self.grab_set() 92 93 # Message 94 ttk.Label( 95 self, text="You have unsaved changes. What would you like to do?", 96 padding=(20, 15, 20, 10), 97 ).pack() 98 99 # Buttons 100 btn_frame = ttk.Frame(self, padding=(10, 5, 10, 15)) 101 btn_frame.pack() 102 103 ttk.Button(btn_frame, text="Save", 104 command=lambda: self._choose("save"), 105 width=14).pack(side=tk.LEFT, padx=4) 106 ttk.Button(btn_frame, text="Save As...", 107 command=lambda: self._choose("save_as"), 108 width=14).pack(side=tk.LEFT, padx=4) 109 ttk.Button(btn_frame, text="Discard Changes", 110 command=lambda: self._choose("discard"), 111 width=14).pack(side=tk.LEFT, padx=4) 112 ttk.Button(btn_frame, text="Cancel", 113 command=lambda: self._choose("cancel"), 114 width=14).pack(side=tk.LEFT, padx=4) 115 116 self.protocol("WM_DELETE_WINDOW", lambda: self._choose("cancel")) 117 self.bind("<Escape>", lambda e: self._choose("cancel")) 118 119 # Center on parent 120 self.update_idletasks() 121 pw = parent.winfo_width() 122 ph = parent.winfo_height() 123 px = parent.winfo_x() 124 py = parent.winfo_y() 125 w = self.winfo_width() 126 h = self.winfo_height() 127 self.geometry(f"+{px + (pw - w) // 2}+{py + (ph - h) // 2}") 128 129 self.wait_window() 130 131 def _choose(self, choice: str): 132 self.result = choice 133 self.destroy() 134 135 136class ControllerConfigApp(tk.Tk): 137 """Main application window.""" 138 139 def __init__(self, initial_file: str | None = None): 140 # Set app ID so Windows taskbar shows our icon instead of python.exe 141 try: 142 import ctypes 143 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( 144 "raptacon.controller_config") 145 except (AttributeError, OSError): 146 pass # Not on Windows or missing API 147 148 super().__init__() 149 self.title("FRC Controller Configuration") 150 self.geometry("1200x700") 151 self.minsize(900, 550) 152 153 self._config = FullConfig() 154 self._current_file: Path | None = None 155 self._dirty = False 156 self._settings = self._load_settings() 157 158 # Restore saved window geometry (size + position) 159 saved_geom = self._settings.get("geometry") 160 if saved_geom: 161 self.geometry(saved_geom) 162 163 # Window icon (title bar + taskbar) 164 icon_path = _project_root / "images" / "Raptacon3200-BG-BW.png" 165 if icon_path.exists(): 166 self._icon_image = tk.PhotoImage(file=str(icon_path)) 167 self.iconphoto(True, self._icon_image) 168 169 # Undo / redo stacks: each entry is (FullConfig, empty_groups_set) 170 self._undo_stack: list[tuple[FullConfig, set[str]]] = [] 171 self._redo_stack: list[tuple[FullConfig, set[str]]] = [] 172 self._last_undo_time: float = 0.0 173 self._restoring: bool = False # Guard against spurious pushes 174 # Snapshot of config at last save/load for accurate dirty tracking 175 self._clean_config: FullConfig = deepcopy(self._config) 176 177 # Drag-and-drop state 178 self._drag_action: str | None = None 179 self._drag_bindings_saved: dict = {} # saved bind_all IDs 180 181 # Icon loader for Xbox controller button icons 182 icons_dir = _project_root / "images" / "XboxControlIcons" / "Buttons Full Solid" 183 self._icon_loader = InputIconLoader(icons_dir, root=self) 184 185 self._build_menu() 186 self._build_layout() 187 188 # Load initial file, last opened file, or set up defaults 189 if initial_file: 190 self._open_file(Path(initial_file)) 191 elif self._settings.get("last_file"): 192 last = Path(self._settings["last_file"]) 193 if last.exists(): 194 self._open_file(last) 195 else: 196 self._new_config() 197 else: 198 self._new_config() 199 200 self._update_title() 201 self.protocol("WM_DELETE_WINDOW", self._on_close) 202 203 # Pre-load saved sash positions so they're ready when <Map> fires, 204 # then restore the active tab after idle (needs geometry). 205 saved_sash = self._settings.get("editor_hsash") 206 if saved_sash and len(saved_sash) >= 2: 207 self._action_editor.set_sash_positions(saved_sash) 208 self.after_idle(self._restore_tab_state) 209 210 def _build_menu(self): 211 menubar = tk.Menu(self) 212 self.config(menu=menubar) 213 214 file_menu = tk.Menu(menubar, tearoff=0) 215 menubar.add_cascade(label="File", menu=file_menu) 216 file_menu.add_command(label="New", command=self._new_config, accelerator="Ctrl+N") 217 file_menu.add_command(label="Open...", command=self._open_dialog, accelerator="Ctrl+O") 218 file_menu.add_separator() 219 file_menu.add_command(label="Save", command=self._save, accelerator="Ctrl+S") 220 file_menu.add_command(label="Save As...", command=self._save_as, accelerator="Ctrl+Shift+S") 221 file_menu.add_separator() 222 file_menu.add_command(label="Import Actions...", 223 command=self._import_actions, accelerator="Ctrl+I") 224 file_menu.add_separator() 225 file_menu.add_command(label="Export All Groups...", 226 command=self._export_all_groups) 227 file_menu.add_command(label="Export Assignments...", 228 command=self._export_assignments) 229 file_menu.add_separator() 230 print_menu = tk.Menu(file_menu, tearoff=0) 231 file_menu.add_cascade(label="Print / Export", menu=print_menu) 232 print_menu.add_command( 233 label="Portrait (2 per page) - PNG...", 234 command=lambda: self._print_export("portrait", "png")) 235 print_menu.add_command( 236 label="Portrait (2 per page) - PDF...", 237 command=lambda: self._print_export("portrait", "pdf")) 238 print_menu.add_command( 239 label="Landscape (1 per page) - PNG...", 240 command=lambda: self._print_export("landscape", "png")) 241 print_menu.add_command( 242 label="Landscape (1 per page) - PDF...", 243 command=lambda: self._print_export("landscape", "pdf")) 244 file_menu.add_separator() 245 file_menu.add_command(label="Exit", command=self._on_close) 246 247 edit_menu = tk.Menu(menubar, tearoff=0) 248 menubar.add_cascade(label="Edit", menu=edit_menu) 249 edit_menu.add_command(label="Undo", command=self._undo, 250 accelerator="Ctrl+Z") 251 edit_menu.add_command(label="Redo", command=self._redo, 252 accelerator="Ctrl+Y") 253 edit_menu.add_separator() 254 self._edit_details_var = tk.BooleanVar( 255 value=self._settings.get("edit_details", False)) 256 edit_menu.add_checkbutton( 257 label="Enable Action Details Edit", 258 variable=self._edit_details_var, 259 command=self._toggle_edit_details) 260 261 view_menu = tk.Menu(menubar, tearoff=0) 262 menubar.add_cascade(label="View", menu=view_menu) 263 self._show_borders_var = tk.BooleanVar( 264 value=self._settings.get("show_borders", False)) 265 view_menu.add_checkbutton(label="Show Button Borders", 266 variable=self._show_borders_var, 267 command=self._toggle_borders) 268 self._lock_labels_var = tk.BooleanVar(value=False) 269 view_menu.add_checkbutton(label="Lock Label Positions", 270 variable=self._lock_labels_var, 271 command=self._toggle_lock_labels) 272 self._hide_unassigned_var = tk.BooleanVar(value=False) 273 view_menu.add_checkbutton(label="Hide Unassigned Inputs", 274 variable=self._hide_unassigned_var, 275 command=self._toggle_hide_unassigned) 276 view_menu.add_command(label="Reset Label Positions", 277 command=self._reset_label_positions) 278 view_menu.add_separator() 279 view_menu.add_command(label="Reset GUI Layout", 280 command=self._reset_gui_layout) 281 282 # --- Advanced menu (session-only, not persisted) --- 283 adv_menu = tk.Menu(menubar, tearoff=0) 284 menubar.add_cascade(label="Advanced", menu=adv_menu) 285 self._adv_splines_var = tk.BooleanVar(value=False) 286 adv_menu.add_checkbutton( 287 label="Enable Splines", 288 variable=self._adv_splines_var, 289 command=self._on_advanced_changed) 290 self._adv_nonmono_var = tk.BooleanVar(value=False) 291 adv_menu.add_checkbutton( 292 label="Enable Non-Monotonic", 293 variable=self._adv_nonmono_var, 294 command=self._on_advanced_changed) 295 296 # --- Help menu --- 297 help_menu = tk.Menu(menubar, tearoff=0) 298 menubar.add_cascade(label="Help", menu=help_menu) 299 help_menu.add_command(label="About...", command=self._show_about) 300 301 self.bind_all("<Control-n>", lambda e: self._new_config()) 302 self.bind_all("<Control-o>", lambda e: self._open_dialog()) 303 self.bind_all("<Control-s>", lambda e: self._save()) 304 self.bind_all("<Control-Shift-S>", lambda e: self._save_as()) 305 self.bind_all("<Control-i>", lambda e: self._import_actions()) 306 self.bind_all("<Control-z>", lambda e: self._undo()) 307 self.bind_all("<Control-y>", lambda e: self._redo()) 308 309 def _build_layout(self): 310 # Main horizontal pane: action panel (left) | controller tabs (right) 311 self._paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) 312 self._paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 313 314 # Left: Action panel 315 self._action_panel = ActionPanel( 316 self._paned, 317 on_actions_changed=self._on_actions_changed, 318 on_export_group=self._export_group, 319 on_drag_start=self._on_drag_start, 320 on_drag_end=self._on_drag_end, 321 on_before_change=self._on_before_action_change, 322 get_binding_info=self._get_binding_info_for_action, 323 on_assign_action=self._context_assign_action, 324 on_unassign_action=self._context_unassign_action, 325 on_unassign_all=self._context_unassign_all, 326 get_all_controllers=self._get_all_controllers, 327 get_compatible_inputs=self._get_compatible_inputs_with_display, 328 is_action_bound=self._is_action_bound_to, 329 on_action_renamed=self._on_action_renamed, 330 on_selection_changed=self._on_action_selection_changed, 331 get_advanced_flags=self.get_advanced_flags, 332 icon_loader=self._icon_loader, 333 ) 334 # Apply initial edit-details state 335 self._action_panel.set_details_editable( 336 self._edit_details_var.get()) 337 self._paned.add(self._action_panel, weight=0) 338 339 # Right: Controller tabs + add button 340 right_frame = ttk.Frame(self._paned) 341 self._paned.add(right_frame, weight=1) 342 343 tab_toolbar = ttk.Frame(right_frame) 344 tab_toolbar.pack(fill=tk.X) 345 ttk.Button(tab_toolbar, text="+ Add Controller", command=self._add_controller_tab).pack( 346 side=tk.RIGHT, padx=5, pady=2) 347 ttk.Button(tab_toolbar, text="- Remove Controller", command=self._remove_controller_tab).pack( 348 side=tk.RIGHT, padx=5, pady=2) 349 350 self._notebook = ttk.Notebook(right_frame) 351 self._notebook.pack(fill=tk.BOTH, expand=True, pady=(5, 0)) 352 353 # Action Editor tab (first tab, before controller tabs) 354 self._action_editor = ActionEditorTab( 355 self._notebook, 356 on_before_change=self._on_before_action_change, 357 on_field_changed=self._on_action_editor_changed, 358 get_binding_info=self._get_binding_info_for_action, 359 on_assign_action=self._context_assign_action, 360 on_unassign_action=self._context_unassign_action, 361 get_all_controllers=self._get_all_controllers, 362 get_compatible_inputs=self._get_compatible_inputs_with_display, 363 is_action_bound=self._is_action_bound_to, 364 get_all_actions=lambda: self._config.actions, 365 get_group_names=self._get_all_group_names, 366 get_advanced_flags=self.get_advanced_flags, 367 icon_loader=self._icon_loader, 368 ) 369 self._notebook.add(self._action_editor, text="Action Editor") 370 371 self._controller_canvases: dict[int, ControllerCanvas] = {} 372 373 # Status bar 374 self._hover_status_active = False 375 self._status_var = tk.StringVar(value="Ready") 376 status_bar = ttk.Label(self, textvariable=self._status_var, relief=tk.SUNKEN, anchor=tk.W) 377 status_bar.pack(fill=tk.X, side=tk.BOTTOM, padx=5, pady=2) 378 379 # --- Settings Persistence --- 380 381 @staticmethod 382 def _load_settings() -> dict: 383 """Load app settings (last opened file, etc.).""" 384 return load_settings() 385 386 def _save_settings(self): 387 """Persist app settings.""" 388 try: 389 _settings_file.write_text(json.dumps(self._settings, indent=2)) 390 except OSError: 391 pass # Non-fatal: settings are convenience, not critical 392 393 def _get_initial_dir(self) -> str: 394 """Return the best initial directory for file dialogs.""" 395 # Use the directory of the current file if one is open 396 if self._current_file and self._current_file.parent.exists(): 397 return str(self._current_file.parent) 398 # Fall back to data/ in the repo 399 if _default_data_dir.exists(): 400 return str(_default_data_dir) 401 return str(_project_root) 402 403 # --- Config Management --- 404 405 def _new_config(self): 406 """Create a new blank configuration with two default controllers.""" 407 if self._dirty and not self._handle_unsaved_changes(): 408 return 409 410 self._config = FullConfig( 411 controllers={ 412 0: ControllerConfig(port=0, name="Driver"), 413 1: ControllerConfig(port=1, name="Operator"), 414 } 415 ) 416 self._current_file = None 417 self._dirty = False 418 self._undo_stack.clear() 419 self._redo_stack.clear() 420 self._clean_config = deepcopy(self._config) 421 self._sync_ui_from_config() 422 self._update_title() 423 self._status_var.set("New configuration created") 424 425 def _open_dialog(self): 426 if self._dirty and not self._handle_unsaved_changes(): 427 return 428 429 path = filedialog.askopenfilename( 430 title="Open Controller Config", 431 initialdir=self._get_initial_dir(), 432 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 433 ) 434 if path: 435 self._open_file(Path(path)) 436 437 def _open_file(self, path: Path): 438 try: 439 self._config = load_config(path) 440 self._current_file = path.resolve() 441 self._dirty = False 442 self._undo_stack.clear() 443 self._redo_stack.clear() 444 self._clean_config = deepcopy(self._config) 445 self._sync_ui_from_config() 446 self._update_title() 447 self._status_var.set(f"Opened: {path.name}") 448 self._settings["last_file"] = str(self._current_file) 449 self._save_settings() 450 # Warn if config version doesn't match expected version 451 from utils.controller.config_io import CONFIG_VERSION 452 if self._config.version and self._config.version != CONFIG_VERSION: 453 messagebox.showwarning( 454 "Version Mismatch", 455 f"Config file version '{self._config.version}' does not " 456 f"match expected version '{CONFIG_VERSION}'.\n\n" 457 "The file may have been created with a different " 458 "version of the tool.") 459 except Exception as e: 460 messagebox.showerror("Error", f"Failed to open file:\n{e}") 461 462 def _save(self): 463 if self._current_file: 464 self._save_to(self._current_file) 465 else: 466 self._save_as() 467 468 def _save_as(self): 469 path = filedialog.asksaveasfilename( 470 title="Save Controller Config", 471 initialdir=self._get_initial_dir(), 472 defaultextension=".yaml", 473 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 474 ) 475 if path: 476 self._save_to(Path(path)) 477 478 def _save_to(self, path: Path): 479 try: 480 self._sync_config_from_ui() 481 save_config(self._config, path) 482 self._current_file = path.resolve() 483 self._dirty = False 484 self._clean_config = deepcopy(self._config) 485 self._update_title() 486 self._status_var.set(f"Saved: {path.name}") 487 self._settings["last_file"] = str(self._current_file) 488 self._save_settings() 489 except Exception as e: 490 messagebox.showerror("Error", f"Failed to save file:\n{e}") 491 492 def _handle_unsaved_changes(self) -> bool: 493 """Prompt user to save/discard/cancel unsaved changes. 494 495 Returns True if the caller should proceed with its action 496 (changes were saved or discarded), False to abort. 497 """ 498 dialog = _UnsavedChangesDialog(self) 499 choice = dialog.result 500 501 if choice == "save": 502 self._save() 503 return not self._dirty # False if save was cancelled 504 elif choice == "save_as": 505 self._save_as() 506 return not self._dirty 507 elif choice == "discard": 508 return True 509 else: # cancel 510 return False 511 512 def _on_close(self): 513 if self._dirty: 514 if not self._handle_unsaved_changes(): 515 return 516 self._settings["geometry"] = self.geometry() 517 # Save active notebook tab 518 try: 519 self._settings["active_tab"] = self._notebook.index( 520 self._notebook.select()) 521 except Exception: 522 pass # Tab index unavailable during teardown 523 # Save Action Editor pane positions (skip if user just reset layout) 524 if not getattr(self, '_sash_reset', False): 525 try: 526 self._settings["editor_hsash"] = [ 527 self._action_editor._hpaned.sash_coord(i)[0] 528 for i in range(2)] 529 except Exception: 530 pass # Sash coords unavailable if panes not yet rendered 531 self._save_settings() 532 self.destroy() 533 534 def _restore_tab_state(self): 535 """Restore saved notebook tab selection.""" 536 saved_tab = self._settings.get("active_tab") 537 if saved_tab is not None: 538 try: 539 self._notebook.select(saved_tab) 540 except Exception: 541 pass # Saved tab index may be out of range 542 543 def _update_title(self): 544 name = self._current_file.name if self._current_file else "Untitled" 545 dirty = " *" if self._dirty else "" 546 self.title(f"FRC Controller Config - {name}{dirty}") 547 548 def _mark_dirty(self): 549 self._dirty = True 550 self._update_title() 551 self._action_panel.update_binding_tags() 552 553 def _is_config_clean(self) -> bool: 554 """Check whether current config matches the last saved/loaded state.""" 555 self._sync_config_from_ui() 556 return self._config == self._clean_config 557 558 # --- Undo / Redo --- 559 560 _UNDO_LIMIT = 50 561 562 def _take_snapshot(self) -> tuple[FullConfig, set[str]]: 563 """Capture current config + action panel empty groups.""" 564 self._sync_config_from_ui() 565 return (deepcopy(self._config), 566 self._action_panel.get_empty_groups()) 567 568 def _push_undo(self, coalesce_ms: int = 0): 569 """Snapshot current state onto the undo stack. 570 571 Args: 572 coalesce_ms: if > 0 and the last push was within this many ms, 573 replace the top of the stack instead of pushing a new entry. 574 Useful for coalescing rapid keystrokes in text fields. 575 """ 576 now = time.monotonic() 577 if coalesce_ms and self._undo_stack: 578 if (now - self._last_undo_time) < (coalesce_ms / 1000.0): 579 self._undo_stack[-1] = self._take_snapshot() 580 self._redo_stack.clear() 581 return 582 self._undo_stack.append(self._take_snapshot()) 583 if len(self._undo_stack) > self._UNDO_LIMIT: 584 self._undo_stack.pop(0) 585 self._redo_stack.clear() 586 self._last_undo_time = now 587 588 def _restore_snapshot(self, config: FullConfig, empty_groups: set[str], 589 restore_selection: str | None = None): 590 """Restore a config snapshot and re-sync the UI.""" 591 self._restoring = True 592 try: 593 self._config = config 594 # Merge legacy undo-stack empty_groups into config 595 self._config.empty_groups = ( 596 self._config.empty_groups | empty_groups) 597 self._sync_ui_from_config(restore_selection) 598 finally: 599 self._restoring = False 600 601 def _undo(self): 602 """Undo the last change.""" 603 if not self._undo_stack: 604 self._status_var.set("Nothing to undo") 605 return 606 selected = self._action_panel._selected_name 607 self._redo_stack.append(self._take_snapshot()) 608 config, empty_groups = self._undo_stack.pop() 609 self._restore_snapshot(config, empty_groups, selected) 610 self._dirty = not self._is_config_clean() 611 self._update_title() 612 self._status_var.set("Undo") 613 614 def _redo(self): 615 """Redo the last undone change.""" 616 if not self._redo_stack: 617 self._status_var.set("Nothing to redo") 618 return 619 selected = self._action_panel._selected_name 620 self._undo_stack.append(self._take_snapshot()) 621 config, empty_groups = self._redo_stack.pop() 622 self._restore_snapshot(config, empty_groups, selected) 623 self._dirty = not self._is_config_clean() 624 self._update_title() 625 self._status_var.set("Redo") 626 627 # --- UI <-> Config Sync --- 628 629 def _sync_ui_from_config(self, restore_selection: str | None = None): 630 """Push config data to all UI elements. 631 632 Args: 633 restore_selection: if provided, re-select this action after 634 rebuilding the tree (used by undo/redo to preserve context). 635 """ 636 # Update action panel 637 self._action_panel.set_actions(self._config.actions) 638 self._action_panel.set_empty_groups(self._config.empty_groups) 639 640 # Update controller tabs — reuse existing canvases when possible 641 new_ports = sorted(self._config.controllers.keys()) 642 old_ports = sorted(self._controller_canvases.keys()) 643 644 # The Action Editor tab is always at index 0; controller tabs follow 645 ctrl_tab_offset = 1 646 647 if new_ports == old_ports: 648 # Same controllers — just update bindings and tab labels in place 649 for idx, port in enumerate(new_ports): 650 ctrl = self._config.controllers[port] 651 self._controller_canvases[port].set_bindings(ctrl.bindings) 652 label = ctrl.name or f"Controller {port}" 653 self._notebook.tab( 654 idx + ctrl_tab_offset, 655 text=f"{label} (Port {port})") 656 else: 657 # Controller set changed — remove controller tabs (keep editor) 658 all_tabs = self._notebook.tabs() 659 for tab_id in all_tabs: 660 widget = self._notebook.nametowidget(tab_id) 661 if widget is not self._action_editor: 662 self._notebook.forget(tab_id) 663 self._controller_canvases.clear() 664 for port in new_ports: 665 ctrl = self._config.controllers[port] 666 self._create_controller_tab(port, ctrl) 667 668 # Restore selection or clear the Action Editor 669 if (restore_selection 670 and restore_selection in self._config.actions): 671 self._action_panel._reselect(restore_selection) 672 else: 673 self._action_editor.clear() 674 675 def _sync_config_from_ui(self): 676 """Pull current UI state back into the config.""" 677 self._config.actions = self._action_panel.get_actions() 678 self._config.empty_groups = self._action_panel.get_empty_groups() 679 680 def _create_controller_tab(self, port: int, ctrl: ControllerConfig): 681 """Create a tab for a controller.""" 682 tab_frame = ttk.Frame(self._notebook) 683 label = ctrl.name or f"Controller {port}" 684 self._notebook.add(tab_frame, text=f"{label} (Port {port})") 685 686 # Name editor at top of tab 687 name_frame = ttk.Frame(tab_frame) 688 name_frame.pack(fill=tk.X, padx=5, pady=5) 689 ttk.Label(name_frame, text="Controller Name:").pack(side=tk.LEFT) 690 name_var = tk.StringVar(value=ctrl.name) 691 name_entry = ttk.Entry(name_frame, textvariable=name_var, width=20) 692 name_entry.pack(side=tk.LEFT, padx=5) 693 694 def on_name_change(*args, p=port, v=name_var): 695 if self._restoring: 696 return 697 if p in self._config.controllers: 698 self._push_undo(coalesce_ms=500) 699 self._config.controllers[p].name = v.get() 700 # Update tab label (offset by 1 for Action Editor tab) 701 idx = sorted(self._config.controllers.keys()).index(p) 702 label_text = v.get() or f"Controller {p}" 703 self._notebook.tab( 704 idx + 1, text=f"{label_text} (Port {p})") 705 self._mark_dirty() 706 707 name_var.trace_add("write", on_name_change) 708 709 # Controller canvas 710 canvas = ControllerCanvas( 711 tab_frame, 712 on_binding_click=lambda input_name, p=port: self._on_binding_click(p, input_name), 713 on_binding_clear=lambda input_name, p=port: self._on_binding_clear(p, input_name), 714 on_mouse_coord=self._on_mouse_coord, 715 on_label_moved=self._on_label_moved, 716 on_hover_input=lambda input_name, p=port: self._on_hover_input(p, input_name), 717 on_hover_shape=lambda input_names, p=port: self._on_hover_shape(p, input_names), 718 on_action_remove=lambda input_name, action, p=port: self._on_action_remove(p, input_name, action), 719 label_positions=self._settings.get("label_positions", {}), 720 icon_loader=self._icon_loader, 721 ) 722 canvas.pack(fill=tk.BOTH, expand=True) 723 canvas.set_bindings(ctrl.bindings) 724 canvas.set_show_borders(self._show_borders_var.get()) 725 canvas.set_labels_locked(self._lock_labels_var.get()) 726 canvas.set_hide_unassigned(self._hide_unassigned_var.get()) 727 728 self._controller_canvases[port] = canvas 729 730 def _add_controller_tab(self): 731 """Add a new controller at the next available port.""" 732 self._push_undo() 733 existing_ports = set(self._config.controllers.keys()) 734 port = 0 735 while port in existing_ports: 736 port += 1 737 738 ctrl = ControllerConfig(port=port, name=f"Controller {port}") 739 self._config.controllers[port] = ctrl 740 self._create_controller_tab(port, ctrl) 741 self._mark_dirty() 742 743 # Select the new tab 744 self._notebook.select(len(self._notebook.tabs()) - 1) 745 746 def _remove_controller_tab(self): 747 """Remove the currently selected controller tab.""" 748 current = self._notebook.index(self._notebook.select()) 749 # Offset by 1 for the Action Editor tab at index 0 750 ctrl_idx = current - 1 751 ports = sorted(self._config.controllers.keys()) 752 if ctrl_idx < 0 or ctrl_idx >= len(ports): 753 return 754 port = ports[ctrl_idx] 755 756 if not messagebox.askyesno("Remove Controller", 757 f"Remove controller on port {port}?"): 758 return 759 760 self._push_undo() 761 del self._config.controllers[port] 762 if port in self._controller_canvases: 763 del self._controller_canvases[port] 764 self._notebook.forget(current) 765 self._mark_dirty() 766 767 # --- Callbacks --- 768 769 def _on_mouse_coord(self, img_x: int, img_y: int): 770 """Update status bar with mouse position in source image pixels.""" 771 # Don't overwrite action info while hovering a binding box 772 if not self._hover_status_active: 773 self._status_var.set(f"Image coords: ({img_x}, {img_y})") 774 775 def _format_action_status(self, port: int, input_names: list[str]) -> str | None: 776 """Build a status string for actions bound to the given inputs.""" 777 ctrl = self._config.controllers.get(port) 778 if not ctrl: 779 return None 780 781 parts = [] 782 for input_name in input_names: 783 for action_name in ctrl.bindings.get(input_name, []): 784 action = self._config.actions.get(action_name) 785 if action: 786 desc = action.description or "No description" 787 atype = action.input_type.value.capitalize() 788 parts.append(f"{action.qualified_name} ({atype}) - {desc}") 789 else: 790 parts.append(action_name) 791 return " | ".join(parts) if parts else None 792 793 def _on_hover_input(self, port: int, input_name: str | None): 794 """Update status bar with action info when hovering a binding box.""" 795 if not input_name: 796 self._hover_status_active = False 797 self._status_var.set("Ready") 798 return 799 800 text = self._format_action_status(port, [input_name]) 801 if text: 802 self._hover_status_active = True 803 self._status_var.set(text) 804 else: 805 self._hover_status_active = False 806 807 def _on_hover_shape(self, port: int, input_names: list[str] | None): 808 """Update status bar with action info when hovering a controller shape.""" 809 if not input_names: 810 self._hover_status_active = False 811 self._status_var.set("Ready") 812 return 813 814 text = self._format_action_status(port, input_names) 815 if text: 816 self._hover_status_active = True 817 self._status_var.set(text) 818 else: 819 self._hover_status_active = False 820 821 def _toggle_borders(self): 822 """Toggle shape border visibility on all canvases.""" 823 show = self._show_borders_var.get() 824 for canvas in self._controller_canvases.values(): 825 canvas.set_show_borders(show) 826 self._settings["show_borders"] = show 827 self._save_settings() 828 829 def _toggle_lock_labels(self): 830 """Toggle label dragging lock on all canvases.""" 831 locked = self._lock_labels_var.get() 832 for canvas in self._controller_canvases.values(): 833 canvas.set_labels_locked(locked) 834 self._status_var.set( 835 "Label positions locked" if locked else "Label positions unlocked") 836 837 def _toggle_hide_unassigned(self): 838 """Toggle hiding of unassigned inputs on all canvases.""" 839 hide = self._hide_unassigned_var.get() 840 for canvas in self._controller_canvases.values(): 841 canvas.set_hide_unassigned(hide) 842 self._status_var.set( 843 "Unassigned inputs hidden" if hide 844 else "Unassigned inputs shown") 845 846 def _on_advanced_changed(self): 847 """Notify children when Advanced menu toggles change.""" 848 self._action_panel.on_advanced_changed() 849 self._action_editor.on_advanced_changed() 850 851 def _toggle_edit_details(self): 852 """Toggle whether the Action Details panel fields are editable.""" 853 enabled = self._edit_details_var.get() 854 self._action_panel.set_details_editable(enabled) 855 self._settings["edit_details"] = enabled 856 self._save_settings() 857 858 def get_advanced_flags(self) -> dict: 859 """Return current advanced feature flags (session-only).""" 860 return { 861 "splines": self._adv_splines_var.get(), 862 "nonmono": self._adv_nonmono_var.get(), 863 } 864 865 def _show_about(self): 866 """Show the About dialog with license information.""" 867 about = tk.Toplevel(self) 868 about.title("About") 869 about.resizable(True, True) 870 about.transient(self) 871 about.grab_set() 872 873 # Read license files 874 proj_license = "" 875 img_license = "" 876 try: 877 lf = _project_root / "LICENSE" 878 if lf.exists(): 879 proj_license = lf.read_text(encoding="utf-8") 880 except OSError: 881 proj_license = "(Could not read LICENSE file)" 882 try: 883 lf = _project_root / "images" / "LICENSE.md" 884 if lf.exists(): 885 img_license = lf.read_text(encoding="utf-8") 886 except OSError: 887 img_license = "(Could not read images/LICENSE.md)" 888 889 sep = "=" * 40 890 content = ( 891 "Raptacon Controller Config\n" 892 "FRC Team 3200\n" 893 "\n" 894 + proj_license.strip() + "\n\n" 895 + sep + "\n" 896 + "Image Licenses\n" 897 + sep + "\n\n" 898 + img_license.strip() + "\n" 899 ) 900 901 text = tk.Text(about, wrap=tk.NONE, width=60, height=25, 902 font=("TkDefaultFont", 9)) 903 scroll = ttk.Scrollbar(about, orient=tk.VERTICAL, 904 command=text.yview) 905 text.configure(yscrollcommand=scroll.set) 906 text.insert("1.0", content) 907 text.configure(state="disabled") 908 scroll.pack(side=tk.RIGHT, fill=tk.Y) 909 text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 910 911 ttk.Button(about, text="Close", 912 command=about.destroy).pack(pady=(0, 8)) 913 914 # Center on parent 915 about.update_idletasks() 916 pw, ph = self.winfo_width(), self.winfo_height() 917 px, py = self.winfo_x(), self.winfo_y() 918 w, h = about.winfo_width(), about.winfo_height() 919 about.geometry(f"+{px + (pw - w) // 2}+{py + (ph - h) // 2}") 920 921 def _reset_label_positions(self): 922 """Reset all dragged label positions to defaults.""" 923 self._settings.pop("label_positions", None) 924 self._save_settings() 925 for canvas in self._controller_canvases.values(): 926 canvas.reset_label_positions() 927 self._status_var.set("Label positions reset to defaults") 928 929 def _reset_gui_layout(self): 930 """Reset GUI layout settings (geometry, tabs, panes) but keep labels.""" 931 for key in ("geometry", "active_tab", "editor_hsash", "show_borders"): 932 self._settings.pop(key, None) 933 self._sash_reset = True # prevent _on_close from re-saving 934 self._save_settings() 935 936 # Reset window geometry 937 self.geometry("1200x700") 938 939 # Reset show-borders toggle 940 self._show_borders_var.set(False) 941 for canvas in self._controller_canvases.values(): 942 canvas.set_show_borders(False) 943 944 # Reset Action Editor sash to equal thirds 945 self._action_editor._sash_applied = False 946 self._action_editor._saved_sash = None 947 try: 948 w = self._action_editor._hpaned.winfo_width() 949 if w > 50: 950 third = w // 3 951 self._action_editor._hpaned.sash_place(0, third, 0) 952 self._action_editor._hpaned.sash_place(1, third * 2, 0) 953 self._action_editor._sash_applied = True 954 except Exception: 955 pass # Pane not yet mapped; sash will be set on next resize 956 957 # Switch to first tab (Action Editor) 958 self._notebook.select(0) 959 self._status_var.set("GUI layout reset to defaults") 960 961 def _on_label_moved(self, input_name: str, img_x: int, img_y: int): 962 """Persist a dragged label position to settings.""" 963 positions = self._settings.setdefault("label_positions", {}) 964 positions[input_name] = [img_x, img_y] 965 self._save_settings() 966 967 # --- Drag-and-Drop (action panel → controller canvas) --- 968 969 def _on_drag_start(self, action_qname: str): 970 """Called when an action drag begins from the action panel.""" 971 self._drag_action = action_qname 972 self._status_var.set(f"Dragging: {action_qname}") 973 self.config(cursor="plus") 974 for c in self._controller_canvases.values(): 975 c.set_drag_cursor(True) 976 # Temporarily show all inputs so user can see all drop targets 977 if self._hide_unassigned_var.get(): 978 for c in self._controller_canvases.values(): 979 c.set_hide_unassigned(False) 980 # Grey out incompatible inputs 981 compatible = self._get_compatible_inputs(action_qname) 982 for c in self._controller_canvases.values(): 983 c.dim_incompatible_inputs(compatible) 984 # Bind global handlers to track drag across widgets 985 self.bind_all("<B1-Motion>", self._on_drag_motion, add="+") 986 self.bind_all("<ButtonRelease-1>", self._on_drag_release, add="+") 987 988 def _on_drag_end(self): 989 """Called when the tree releases the mouse (safety cleanup).""" 990 self._drag_cleanup() 991 992 def _on_drag_motion(self, event): 993 """Track drag across widgets, highlighting drop targets.""" 994 if not self._drag_action: 995 return 996 997 canvas = self._find_canvas_at(event.x_root, event.y_root) 998 999 # Clear highlights on all canvases except the one under cursor 1000 for c in self._controller_canvases.values(): 1001 if c is not canvas: 1002 c.clear_drop_highlight() 1003 1004 if canvas: 1005 input_name = canvas.highlight_drop_target( 1006 event.x_root, event.y_root) 1007 if input_name: 1008 inp = XBOX_INPUT_MAP.get(input_name) 1009 display = inp.display_name if inp else input_name 1010 self._status_var.set( 1011 f"Drop to bind: {self._drag_action} \u2192 {display}") 1012 else: 1013 self._status_var.set(f"Dragging: {self._drag_action}") 1014 else: 1015 self._status_var.set(f"Dragging: {self._drag_action}") 1016 1017 def _on_drag_release(self, event): 1018 """Handle drop onto controller canvas.""" 1019 action = self._drag_action 1020 if not action: 1021 self._drag_cleanup() 1022 return 1023 1024 canvas = self._find_canvas_at(event.x_root, event.y_root) 1025 port = self._port_for_canvas(canvas) if canvas else None 1026 1027 if canvas and port is not None: 1028 input_name, shape = canvas.get_drop_target( 1029 event.x_root, event.y_root) 1030 1031 if input_name: 1032 self._bind_dropped_action(port, input_name, action) 1033 elif shape: 1034 # Multi-input shape: show picker menu 1035 self._show_drop_input_menu( 1036 event, port, shape, action) 1037 # Full cleanup after menu closes (selection or dismiss) 1038 self._drag_cleanup() 1039 return 1040 1041 self._drag_cleanup() 1042 1043 def _check_type_compatible(self, action_qname: str, 1044 input_name: str) -> bool: 1045 """Check if an action's type is compatible with a controller input. 1046 1047 Shows a warning messagebox if incompatible. 1048 Returns True if compatible, False otherwise. 1049 """ 1050 action_def = self._config.actions.get(action_qname) 1051 inp = XBOX_INPUT_MAP.get(input_name) 1052 if not action_def or not inp: 1053 return True # Can't validate, allow it 1054 1055 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1056 if allowed is None: 1057 return True # Unknown input type, allow it 1058 1059 if action_def.input_type in allowed: 1060 return True 1061 1062 # Type mismatch — show warning popup 1063 action_type = action_def.input_type.value.capitalize() 1064 hint = _INPUT_TYPE_DESCRIPTION.get(inp.input_type, "") 1065 messagebox.showwarning( 1066 "Type Mismatch", 1067 f"Cannot bind '{action_qname}' ({action_type}) " 1068 f"to '{inp.display_name}' ({inp.input_type}).\n\n" 1069 f"{hint}.", 1070 ) 1071 return False 1072 1073 def _get_compatible_actions(self, input_name: str) -> list[str]: 1074 """Return action qualified names compatible with the given input.""" 1075 inp = XBOX_INPUT_MAP.get(input_name) 1076 if not inp: 1077 return list(self._config.actions.keys()) 1078 1079 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1080 if allowed is None: 1081 return list(self._config.actions.keys()) 1082 1083 return [ 1084 qname for qname, action_def in self._config.actions.items() 1085 if action_def.input_type in allowed 1086 ] 1087 1088 def _get_compatible_inputs(self, action_qname: str) -> set[str]: 1089 """Return the set of input names compatible with the given action.""" 1090 action_def = self._config.actions.get(action_qname) 1091 if not action_def: 1092 return {inp.name for inp in XBOX_INPUT_MAP.values()} 1093 compatible = set() 1094 for inp in XBOX_INPUT_MAP.values(): 1095 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1096 if allowed is None or action_def.input_type in allowed: 1097 compatible.add(inp.name) 1098 return compatible 1099 1100 def _get_binding_info_for_action( 1101 self, qname: str 1102 ) -> list[tuple[str, str, str]]: 1103 """Return list of (controller_name, input_display, input_name). 1104 1105 Used by ActionPanel for tooltips, color-coding, and icons. 1106 """ 1107 result = [] 1108 for port, ctrl in self._config.controllers.items(): 1109 ctrl_label = ctrl.name or f"Controller {port}" 1110 for input_name, actions in ctrl.bindings.items(): 1111 if qname in actions: 1112 inp = XBOX_INPUT_MAP.get(input_name) 1113 display = inp.display_name if inp else input_name 1114 result.append((ctrl_label, display, input_name)) 1115 return result 1116 1117 def _get_all_group_names(self) -> list[str]: 1118 """Delegate to ActionPanel — single source of truth for group names.""" 1119 return self._action_panel.get_group_names() 1120 1121 def _get_all_controllers(self) -> list[tuple[int, str]]: 1122 """Return list of (port, controller_name) for the context menu.""" 1123 return [ 1124 (port, ctrl.name or f"Controller {port}") 1125 for port, ctrl in sorted(self._config.controllers.items()) 1126 ] 1127 1128 def _get_compatible_inputs_with_display( 1129 self, qname: str) -> list[tuple[str, str]]: 1130 """Return list of (input_name, display_name) compatible with action.""" 1131 compatible_names = self._get_compatible_inputs(qname) 1132 result = [] 1133 for inp in XBOX_INPUT_MAP.values(): 1134 if inp.name in compatible_names: 1135 result.append((inp.name, inp.display_name)) 1136 return result 1137 1138 def _is_action_bound_to(self, qname: str, port: int, 1139 input_name: str) -> bool: 1140 """Check if action is bound to a specific input on a controller.""" 1141 ctrl = self._config.controllers.get(port) 1142 if not ctrl: 1143 return False 1144 return qname in ctrl.bindings.get(input_name, []) 1145 1146 def _context_assign_action(self, qname: str, port: int, 1147 input_name: str): 1148 """Assign an action to an input from the context menu.""" 1149 self._bind_dropped_action(port, input_name, qname) 1150 1151 def _context_unassign_action(self, qname: str, port: int, 1152 input_name: str): 1153 """Unassign an action from an input via the context menu.""" 1154 self._on_action_remove(port, input_name, qname) 1155 1156 def _context_unassign_all(self, qname: str): 1157 """Remove an action from all inputs on all controllers.""" 1158 self._push_undo() 1159 changed = False 1160 for port, ctrl in self._config.controllers.items(): 1161 for input_name in list(ctrl.bindings.keys()): 1162 actions = ctrl.bindings[input_name] 1163 if qname in actions: 1164 actions.remove(qname) 1165 changed = True 1166 if not actions: 1167 del ctrl.bindings[input_name] 1168 canvas = self._controller_canvases.get(port) 1169 if canvas: 1170 canvas.set_bindings(ctrl.bindings) 1171 if changed: 1172 self._mark_dirty() 1173 self._action_editor.refresh_bindings() 1174 self._status_var.set(f"Removed {qname} from all inputs") 1175 1176 def _bind_dropped_action(self, port: int, input_name: str, action: str): 1177 """Add an action binding from a drag-and-drop, preventing duplicates.""" 1178 ctrl = self._config.controllers.get(port) 1179 if not ctrl: 1180 return 1181 1182 inp = XBOX_INPUT_MAP.get(input_name) 1183 display = inp.display_name if inp else input_name 1184 1185 # Type compatibility check 1186 if not self._check_type_compatible(action, input_name): 1187 return 1188 1189 current = ctrl.bindings.get(input_name, []) 1190 if action in current: 1191 self._status_var.set( 1192 f"{action} already bound to {display}") 1193 return 1194 1195 self._push_undo() 1196 ctrl.bindings.setdefault(input_name, []).append(action) 1197 canvas = self._controller_canvases.get(port) 1198 if canvas: 1199 canvas.set_bindings(ctrl.bindings) 1200 self._mark_dirty() 1201 self._action_editor.refresh_bindings() 1202 self._status_var.set(f"Bound {action} \u2192 {display}") 1203 1204 def _show_drop_input_menu(self, event, port: int, shape, action: str): 1205 """Show menu to pick which input of a multi-input shape to bind to.""" 1206 menu = tk.Menu(self, tearoff=0) 1207 for input_name in shape.inputs: 1208 inp = XBOX_INPUT_MAP.get(input_name) 1209 display = inp.display_name if inp else input_name 1210 menu.add_command( 1211 label=display, 1212 command=lambda n=input_name: self._bind_dropped_action( 1213 port, n, action), 1214 ) 1215 menu.tk_popup(event.x_root, event.y_root) 1216 1217 def _find_canvas_at(self, x_root: int, y_root: int): 1218 """Find the ControllerCanvas widget under the given root coordinates.""" 1219 widget = self.winfo_containing(x_root, y_root) 1220 while widget: 1221 if isinstance(widget, ControllerCanvas): 1222 return widget 1223 widget = getattr(widget, 'master', None) 1224 return None 1225 1226 def _port_for_canvas(self, canvas: ControllerCanvas) -> int | None: 1227 """Return the port number for a given canvas widget.""" 1228 for port, c in self._controller_canvases.items(): 1229 if c is canvas: 1230 return port 1231 return None 1232 1233 def _unbind_drag_handlers(self): 1234 """Remove global drag event handlers.""" 1235 self.unbind_all("<B1-Motion>") 1236 self.unbind_all("<ButtonRelease-1>") 1237 1238 def _drag_cleanup(self): 1239 """Reset all drag state.""" 1240 self._drag_action = None 1241 self._unbind_drag_handlers() 1242 self.config(cursor="") 1243 for c in self._controller_canvases.values(): 1244 c.set_drag_cursor(False) 1245 for c in self._controller_canvases.values(): 1246 c.clear_drop_highlight() 1247 c.clear_dim_overlays() 1248 # Restore hide-unassigned state after drag 1249 if self._hide_unassigned_var.get(): 1250 for c in self._controller_canvases.values(): 1251 c.set_hide_unassigned(True) 1252 if not self._hover_status_active: 1253 self._status_var.set("Ready") 1254 1255 def _on_before_action_change(self, coalesce_ms: int): 1256 """Called by ActionPanel BEFORE it mutates actions (for undo snapshot).""" 1257 if self._restoring: 1258 return 1259 self._push_undo(coalesce_ms=coalesce_ms) 1260 1261 def _on_action_selection_changed(self, qname: str | None): 1262 """Sync Action Editor tab when tree selection changes.""" 1263 if qname: 1264 action = self._config.actions.get(qname) 1265 if action: 1266 self._action_editor.load_action(action, qname) 1267 return 1268 self._action_editor.clear() 1269 1270 def _on_action_editor_changed(self): 1271 """Sync sidebar and mark dirty when Action Editor edits a field.""" 1272 if self._restoring: 1273 return 1274 1275 # Detect name/group change: the editor's qname is the old key, 1276 # but the action object already has the new name/group. 1277 old_qname = self._action_editor._qname 1278 action = self._action_editor._action 1279 if old_qname and action: 1280 new_qname = action.qualified_name 1281 if new_qname != old_qname: 1282 if self._action_panel.rename_action(old_qname, new_qname): 1283 # Update the editor's tracked qname to match 1284 self._action_editor._qname = new_qname 1285 else: 1286 # Rename rejected (duplicate) — revert the action object 1287 parts = old_qname.split(".", 1) 1288 action.group = parts[0] 1289 action.name = parts[1] if len(parts) > 1 else parts[0] 1290 self._action_editor.load_action(action, old_qname) 1291 return 1292 1293 self._config.actions = self._action_panel.get_actions() 1294 # Reload the sidebar detail form to show updated values 1295 selected = self._action_panel._selected_name 1296 if selected: 1297 self._action_panel._load_detail(selected) 1298 self._mark_dirty() 1299 # Refresh controller canvases in case bindings changed 1300 for port, canvas in self._controller_canvases.items(): 1301 ctrl = self._config.controllers.get(port) 1302 if ctrl: 1303 canvas.set_bindings(ctrl.bindings) 1304 1305 def _on_actions_changed(self): 1306 """Called when actions are added/removed/modified in the action panel.""" 1307 if self._restoring: 1308 return 1309 self._config.actions = self._action_panel.get_actions() 1310 self._mark_dirty() 1311 self._check_orphan_bindings() 1312 # Sync Action Editor tab 1313 selected = self._action_panel._selected_name 1314 if selected: 1315 action = self._config.actions.get(selected) 1316 if action: 1317 self._action_editor.load_action(action, selected) 1318 return 1319 self._action_editor.clear() 1320 1321 def _check_orphan_bindings(self): 1322 """Detect and offer to remove bindings referencing deleted actions.""" 1323 orphans = [] 1324 for port, ctrl in self._config.controllers.items(): 1325 ctrl_label = ctrl.name or f"Controller {port}" 1326 for input_name, actions in ctrl.bindings.items(): 1327 for qname in actions: 1328 if qname not in self._config.actions: 1329 inp = XBOX_INPUT_MAP.get(input_name) 1330 display = inp.display_name if inp else input_name 1331 orphans.append((port, input_name, qname, 1332 ctrl_label, display)) 1333 if not orphans: 1334 return 1335 1336 lines = [f" {o[3]} / {o[4]}: {o[2]}" for o in orphans] 1337 detail = "\n".join(lines) 1338 msg = ( 1339 "The following bindings reference actions that no " 1340 f"longer exist:\n\n{detail}" 1341 "\n\nRemove these orphaned bindings?" 1342 ) 1343 if messagebox.askyesno("Orphaned Bindings", msg, parent=self): 1344 for port, input_name, qname, _, _ in orphans: 1345 ctrl = self._config.controllers.get(port) 1346 if not ctrl: 1347 continue 1348 actions = ctrl.bindings.get(input_name, []) 1349 if qname in actions: 1350 actions.remove(qname) 1351 if not actions and input_name in ctrl.bindings: 1352 del ctrl.bindings[input_name] 1353 # Refresh canvases 1354 for port, ctrl in self._config.controllers.items(): 1355 canvas = self._controller_canvases.get(port) 1356 if canvas: 1357 canvas.set_bindings(ctrl.bindings) 1358 self._status_var.set( 1359 f"Removed {len(orphans)} orphaned binding(s)") 1360 1361 def _on_action_renamed(self, old_qname: str, new_qname: str): 1362 """Update all binding references when an action's qualified name changes.""" 1363 for port, ctrl in self._config.controllers.items(): 1364 changed = False 1365 for input_name, actions in ctrl.bindings.items(): 1366 if old_qname in actions: 1367 idx = actions.index(old_qname) 1368 actions[idx] = new_qname 1369 changed = True 1370 if changed: 1371 canvas = self._controller_canvases.get(port) 1372 if canvas: 1373 canvas.set_bindings(ctrl.bindings) 1374 1375 def _on_binding_clear(self, port: int, input_name: str): 1376 """Clear all bindings for a specific input.""" 1377 ctrl = self._config.controllers.get(port) 1378 if not ctrl: 1379 return 1380 if input_name in ctrl.bindings: 1381 self._push_undo() 1382 del ctrl.bindings[input_name] 1383 canvas = self._controller_canvases.get(port) 1384 if canvas: 1385 canvas.set_bindings(ctrl.bindings) 1386 self._mark_dirty() 1387 self._action_editor.refresh_bindings() 1388 1389 def _on_action_remove(self, port: int, input_name: str, action: str): 1390 """Remove a single action from an input's bindings.""" 1391 ctrl = self._config.controllers.get(port) 1392 if not ctrl: 1393 return 1394 actions = ctrl.bindings.get(input_name, []) 1395 if action in actions: 1396 self._push_undo() 1397 actions.remove(action) 1398 if not actions: 1399 del ctrl.bindings[input_name] 1400 canvas = self._controller_canvases.get(port) 1401 if canvas: 1402 canvas.set_bindings(ctrl.bindings) 1403 self._mark_dirty() 1404 self._action_editor.refresh_bindings() 1405 self._status_var.set(f"Removed {action} from {input_name}") 1406 1407 def _on_binding_click(self, port: int, input_name: str): 1408 """Open the binding dialog for a specific input on a specific controller.""" 1409 ctrl = self._config.controllers.get(port) 1410 if not ctrl: 1411 return 1412 1413 current_actions = ctrl.bindings.get(input_name, []) 1414 # Only show actions whose type is compatible with this input 1415 available_actions = self._get_compatible_actions(input_name) 1416 1417 # Build description map for the dialog 1418 descriptions = { 1419 qname: act.description 1420 for qname, act in self._config.actions.items() 1421 if act.description 1422 } 1423 1424 dialog = BindingDialog(self, input_name, current_actions, 1425 available_actions, descriptions) 1426 result = dialog.get_result() 1427 1428 canvas = self._controller_canvases.get(port) 1429 1430 if result is not None: 1431 self._push_undo() 1432 if result: 1433 ctrl.bindings[input_name] = result 1434 elif input_name in ctrl.bindings: 1435 del ctrl.bindings[input_name] 1436 1437 # Refresh the canvas 1438 if canvas: 1439 canvas.set_bindings(ctrl.bindings) 1440 self._mark_dirty() 1441 self._action_editor.refresh_bindings() 1442 1443 # Clear selection so line returns to default color 1444 if canvas: 1445 canvas.clear_selection() 1446 1447 # --- Import / Export --- 1448 1449 def _import_actions(self): 1450 """Import actions from another YAML file, merging with current config.""" 1451 path = filedialog.askopenfilename( 1452 title="Import Actions From...", 1453 initialdir=self._get_initial_dir(), 1454 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1455 ) 1456 if not path: 1457 return 1458 1459 try: 1460 imported = load_actions_from_file(Path(path)) 1461 except Exception as e: 1462 messagebox.showerror("Import Error", f"Failed to load file:\n{e}") 1463 return 1464 1465 if not imported: 1466 messagebox.showinfo("Import", "No actions found in the selected file.") 1467 return 1468 1469 current = self._action_panel.get_actions() 1470 1471 # Separate conflicts from non-conflicts 1472 conflicts = {qname for qname in imported if qname in current} 1473 non_conflicts = {qname: action for qname, action in imported.items() 1474 if qname not in conflicts} 1475 1476 # Resolve conflicts via dialog 1477 resolved = {} 1478 if conflicts: 1479 dialog = ImportConflictDialog(self, conflicts, current, imported) 1480 result = dialog.get_result() 1481 if result is None: 1482 return # User canceled 1483 resolved = result 1484 1485 # Merge 1486 self._push_undo() 1487 merged = dict(current) 1488 merged.update(non_conflicts) 1489 merged.update(resolved) 1490 1491 self._restoring = True # Prevent _on_actions_changed from pushing 1492 self._action_panel.set_actions(merged) 1493 self._restoring = False 1494 self._config.actions = self._action_panel.get_actions() 1495 self._mark_dirty() 1496 1497 count = len(non_conflicts) + len(resolved) 1498 self._status_var.set( 1499 f"Imported {count} action(s) from {Path(path).name}") 1500 1501 def _export_group(self, group_name: str): 1502 """Export a single group's actions to a YAML file.""" 1503 self._sync_config_from_ui() 1504 1505 group_actions = { 1506 qname: action 1507 for qname, action in self._config.actions.items() 1508 if action.group == group_name 1509 } 1510 1511 if not group_actions: 1512 messagebox.showinfo("Export", f"Group '{group_name}' has no actions.") 1513 return 1514 1515 path = filedialog.asksaveasfilename( 1516 title=f"Export Group: {group_name}", 1517 initialdir=self._get_initial_dir(), 1518 initialfile=f"{group_name}.yaml", 1519 defaultextension=".yaml", 1520 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1521 ) 1522 if not path: 1523 return 1524 1525 try: 1526 save_actions_to_file(group_actions, Path(path)) 1527 self._status_var.set( 1528 f"Exported group '{group_name}' to {Path(path).name}") 1529 except Exception as e: 1530 messagebox.showerror("Export Error", f"Failed to export:\n{e}") 1531 1532 def _export_all_groups(self): 1533 """Export each group as a separate YAML file in a chosen directory.""" 1534 self._sync_config_from_ui() 1535 1536 if not self._config.actions: 1537 messagebox.showinfo("Export", "No actions to export.") 1538 return 1539 1540 directory = filedialog.askdirectory( 1541 title="Export All Groups To...", 1542 initialdir=self._get_initial_dir(), 1543 ) 1544 if not directory: 1545 return 1546 1547 # Group actions by group name 1548 groups: dict[str, dict[str, object]] = {} 1549 for qname, action in self._config.actions.items(): 1550 groups.setdefault(action.group, {})[qname] = action 1551 1552 try: 1553 for group_name, group_actions in groups.items(): 1554 out_path = Path(directory) / f"{group_name}.yaml" 1555 save_actions_to_file(group_actions, out_path) 1556 1557 self._status_var.set( 1558 f"Exported {len(groups)} group(s) to {directory}") 1559 except Exception as e: 1560 messagebox.showerror("Export Error", f"Failed to export:\n{e}") 1561 1562 def _print_export(self, orientation: str, fmt: str): 1563 """Export controller layouts as PNG or PDF.""" 1564 self._sync_config_from_ui() 1565 1566 if not self._config.controllers: 1567 messagebox.showinfo("Export", "No controllers to export.") 1568 return 1569 1570 ext = f".{fmt}" 1571 filetypes = ( 1572 [("PNG files", "*.png"), ("All files", "*.*")] if fmt == "png" 1573 else [("PDF files", "*.pdf"), ("All files", "*.*")] 1574 ) 1575 path = filedialog.asksaveasfilename( 1576 title=f"Export {orientation.title()} {fmt.upper()}", 1577 initialdir=self._get_initial_dir(), 1578 initialfile=f"controllers_{orientation}{ext}", 1579 defaultextension=ext, 1580 filetypes=filetypes, 1581 ) 1582 if not path: 1583 return 1584 1585 try: 1586 label_positions = self._settings.get("label_positions", {}) 1587 export_pages(self._config, orientation, Path(path), 1588 label_positions, 1589 self._hide_unassigned_var.get(), 1590 self._icon_loader) 1591 self._status_var.set( 1592 f"Exported {orientation} {fmt.upper()} to {Path(path).name}") 1593 except Exception as e: 1594 messagebox.showerror("Export Error", 1595 f"Failed to export:\n{e}") 1596 1597 def _export_assignments(self): 1598 """Export controller assignments (no actions) to a YAML file.""" 1599 self._sync_config_from_ui() 1600 1601 if not self._config.controllers: 1602 messagebox.showinfo("Export", "No controllers to export.") 1603 return 1604 1605 path = filedialog.asksaveasfilename( 1606 title="Export Assignments", 1607 initialdir=self._get_initial_dir(), 1608 initialfile="assignments.yaml", 1609 defaultextension=".yaml", 1610 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1611 ) 1612 if not path: 1613 return 1614 1615 try: 1616 save_assignments_to_file(self._config.controllers, Path(path)) 1617 self._status_var.set(f"Exported assignments to {Path(path).name}") 1618 except Exception as e: 1619 messagebox.showerror("Export Error", f"Failed to export:\n{e}")
def
load_settings() -> dict:
53def load_settings() -> dict: 54 """Load app settings from the settings file. 55 56 Usable without instantiating the GUI (e.g. from CLI). 57 """ 58 try: 59 if _settings_file.exists(): 60 return json.loads(_settings_file.read_text()) 61 except (json.JSONDecodeError, OSError): 62 pass # Corrupt or missing settings file; fall back to defaults 63 return {}
Load app settings from the settings file.
Usable without instantiating the GUI (e.g. from CLI).
class
ControllerConfigApp(tkinter.Tk):
137class ControllerConfigApp(tk.Tk): 138 """Main application window.""" 139 140 def __init__(self, initial_file: str | None = None): 141 # Set app ID so Windows taskbar shows our icon instead of python.exe 142 try: 143 import ctypes 144 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( 145 "raptacon.controller_config") 146 except (AttributeError, OSError): 147 pass # Not on Windows or missing API 148 149 super().__init__() 150 self.title("FRC Controller Configuration") 151 self.geometry("1200x700") 152 self.minsize(900, 550) 153 154 self._config = FullConfig() 155 self._current_file: Path | None = None 156 self._dirty = False 157 self._settings = self._load_settings() 158 159 # Restore saved window geometry (size + position) 160 saved_geom = self._settings.get("geometry") 161 if saved_geom: 162 self.geometry(saved_geom) 163 164 # Window icon (title bar + taskbar) 165 icon_path = _project_root / "images" / "Raptacon3200-BG-BW.png" 166 if icon_path.exists(): 167 self._icon_image = tk.PhotoImage(file=str(icon_path)) 168 self.iconphoto(True, self._icon_image) 169 170 # Undo / redo stacks: each entry is (FullConfig, empty_groups_set) 171 self._undo_stack: list[tuple[FullConfig, set[str]]] = [] 172 self._redo_stack: list[tuple[FullConfig, set[str]]] = [] 173 self._last_undo_time: float = 0.0 174 self._restoring: bool = False # Guard against spurious pushes 175 # Snapshot of config at last save/load for accurate dirty tracking 176 self._clean_config: FullConfig = deepcopy(self._config) 177 178 # Drag-and-drop state 179 self._drag_action: str | None = None 180 self._drag_bindings_saved: dict = {} # saved bind_all IDs 181 182 # Icon loader for Xbox controller button icons 183 icons_dir = _project_root / "images" / "XboxControlIcons" / "Buttons Full Solid" 184 self._icon_loader = InputIconLoader(icons_dir, root=self) 185 186 self._build_menu() 187 self._build_layout() 188 189 # Load initial file, last opened file, or set up defaults 190 if initial_file: 191 self._open_file(Path(initial_file)) 192 elif self._settings.get("last_file"): 193 last = Path(self._settings["last_file"]) 194 if last.exists(): 195 self._open_file(last) 196 else: 197 self._new_config() 198 else: 199 self._new_config() 200 201 self._update_title() 202 self.protocol("WM_DELETE_WINDOW", self._on_close) 203 204 # Pre-load saved sash positions so they're ready when <Map> fires, 205 # then restore the active tab after idle (needs geometry). 206 saved_sash = self._settings.get("editor_hsash") 207 if saved_sash and len(saved_sash) >= 2: 208 self._action_editor.set_sash_positions(saved_sash) 209 self.after_idle(self._restore_tab_state) 210 211 def _build_menu(self): 212 menubar = tk.Menu(self) 213 self.config(menu=menubar) 214 215 file_menu = tk.Menu(menubar, tearoff=0) 216 menubar.add_cascade(label="File", menu=file_menu) 217 file_menu.add_command(label="New", command=self._new_config, accelerator="Ctrl+N") 218 file_menu.add_command(label="Open...", command=self._open_dialog, accelerator="Ctrl+O") 219 file_menu.add_separator() 220 file_menu.add_command(label="Save", command=self._save, accelerator="Ctrl+S") 221 file_menu.add_command(label="Save As...", command=self._save_as, accelerator="Ctrl+Shift+S") 222 file_menu.add_separator() 223 file_menu.add_command(label="Import Actions...", 224 command=self._import_actions, accelerator="Ctrl+I") 225 file_menu.add_separator() 226 file_menu.add_command(label="Export All Groups...", 227 command=self._export_all_groups) 228 file_menu.add_command(label="Export Assignments...", 229 command=self._export_assignments) 230 file_menu.add_separator() 231 print_menu = tk.Menu(file_menu, tearoff=0) 232 file_menu.add_cascade(label="Print / Export", menu=print_menu) 233 print_menu.add_command( 234 label="Portrait (2 per page) - PNG...", 235 command=lambda: self._print_export("portrait", "png")) 236 print_menu.add_command( 237 label="Portrait (2 per page) - PDF...", 238 command=lambda: self._print_export("portrait", "pdf")) 239 print_menu.add_command( 240 label="Landscape (1 per page) - PNG...", 241 command=lambda: self._print_export("landscape", "png")) 242 print_menu.add_command( 243 label="Landscape (1 per page) - PDF...", 244 command=lambda: self._print_export("landscape", "pdf")) 245 file_menu.add_separator() 246 file_menu.add_command(label="Exit", command=self._on_close) 247 248 edit_menu = tk.Menu(menubar, tearoff=0) 249 menubar.add_cascade(label="Edit", menu=edit_menu) 250 edit_menu.add_command(label="Undo", command=self._undo, 251 accelerator="Ctrl+Z") 252 edit_menu.add_command(label="Redo", command=self._redo, 253 accelerator="Ctrl+Y") 254 edit_menu.add_separator() 255 self._edit_details_var = tk.BooleanVar( 256 value=self._settings.get("edit_details", False)) 257 edit_menu.add_checkbutton( 258 label="Enable Action Details Edit", 259 variable=self._edit_details_var, 260 command=self._toggle_edit_details) 261 262 view_menu = tk.Menu(menubar, tearoff=0) 263 menubar.add_cascade(label="View", menu=view_menu) 264 self._show_borders_var = tk.BooleanVar( 265 value=self._settings.get("show_borders", False)) 266 view_menu.add_checkbutton(label="Show Button Borders", 267 variable=self._show_borders_var, 268 command=self._toggle_borders) 269 self._lock_labels_var = tk.BooleanVar(value=False) 270 view_menu.add_checkbutton(label="Lock Label Positions", 271 variable=self._lock_labels_var, 272 command=self._toggle_lock_labels) 273 self._hide_unassigned_var = tk.BooleanVar(value=False) 274 view_menu.add_checkbutton(label="Hide Unassigned Inputs", 275 variable=self._hide_unassigned_var, 276 command=self._toggle_hide_unassigned) 277 view_menu.add_command(label="Reset Label Positions", 278 command=self._reset_label_positions) 279 view_menu.add_separator() 280 view_menu.add_command(label="Reset GUI Layout", 281 command=self._reset_gui_layout) 282 283 # --- Advanced menu (session-only, not persisted) --- 284 adv_menu = tk.Menu(menubar, tearoff=0) 285 menubar.add_cascade(label="Advanced", menu=adv_menu) 286 self._adv_splines_var = tk.BooleanVar(value=False) 287 adv_menu.add_checkbutton( 288 label="Enable Splines", 289 variable=self._adv_splines_var, 290 command=self._on_advanced_changed) 291 self._adv_nonmono_var = tk.BooleanVar(value=False) 292 adv_menu.add_checkbutton( 293 label="Enable Non-Monotonic", 294 variable=self._adv_nonmono_var, 295 command=self._on_advanced_changed) 296 297 # --- Help menu --- 298 help_menu = tk.Menu(menubar, tearoff=0) 299 menubar.add_cascade(label="Help", menu=help_menu) 300 help_menu.add_command(label="About...", command=self._show_about) 301 302 self.bind_all("<Control-n>", lambda e: self._new_config()) 303 self.bind_all("<Control-o>", lambda e: self._open_dialog()) 304 self.bind_all("<Control-s>", lambda e: self._save()) 305 self.bind_all("<Control-Shift-S>", lambda e: self._save_as()) 306 self.bind_all("<Control-i>", lambda e: self._import_actions()) 307 self.bind_all("<Control-z>", lambda e: self._undo()) 308 self.bind_all("<Control-y>", lambda e: self._redo()) 309 310 def _build_layout(self): 311 # Main horizontal pane: action panel (left) | controller tabs (right) 312 self._paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) 313 self._paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 314 315 # Left: Action panel 316 self._action_panel = ActionPanel( 317 self._paned, 318 on_actions_changed=self._on_actions_changed, 319 on_export_group=self._export_group, 320 on_drag_start=self._on_drag_start, 321 on_drag_end=self._on_drag_end, 322 on_before_change=self._on_before_action_change, 323 get_binding_info=self._get_binding_info_for_action, 324 on_assign_action=self._context_assign_action, 325 on_unassign_action=self._context_unassign_action, 326 on_unassign_all=self._context_unassign_all, 327 get_all_controllers=self._get_all_controllers, 328 get_compatible_inputs=self._get_compatible_inputs_with_display, 329 is_action_bound=self._is_action_bound_to, 330 on_action_renamed=self._on_action_renamed, 331 on_selection_changed=self._on_action_selection_changed, 332 get_advanced_flags=self.get_advanced_flags, 333 icon_loader=self._icon_loader, 334 ) 335 # Apply initial edit-details state 336 self._action_panel.set_details_editable( 337 self._edit_details_var.get()) 338 self._paned.add(self._action_panel, weight=0) 339 340 # Right: Controller tabs + add button 341 right_frame = ttk.Frame(self._paned) 342 self._paned.add(right_frame, weight=1) 343 344 tab_toolbar = ttk.Frame(right_frame) 345 tab_toolbar.pack(fill=tk.X) 346 ttk.Button(tab_toolbar, text="+ Add Controller", command=self._add_controller_tab).pack( 347 side=tk.RIGHT, padx=5, pady=2) 348 ttk.Button(tab_toolbar, text="- Remove Controller", command=self._remove_controller_tab).pack( 349 side=tk.RIGHT, padx=5, pady=2) 350 351 self._notebook = ttk.Notebook(right_frame) 352 self._notebook.pack(fill=tk.BOTH, expand=True, pady=(5, 0)) 353 354 # Action Editor tab (first tab, before controller tabs) 355 self._action_editor = ActionEditorTab( 356 self._notebook, 357 on_before_change=self._on_before_action_change, 358 on_field_changed=self._on_action_editor_changed, 359 get_binding_info=self._get_binding_info_for_action, 360 on_assign_action=self._context_assign_action, 361 on_unassign_action=self._context_unassign_action, 362 get_all_controllers=self._get_all_controllers, 363 get_compatible_inputs=self._get_compatible_inputs_with_display, 364 is_action_bound=self._is_action_bound_to, 365 get_all_actions=lambda: self._config.actions, 366 get_group_names=self._get_all_group_names, 367 get_advanced_flags=self.get_advanced_flags, 368 icon_loader=self._icon_loader, 369 ) 370 self._notebook.add(self._action_editor, text="Action Editor") 371 372 self._controller_canvases: dict[int, ControllerCanvas] = {} 373 374 # Status bar 375 self._hover_status_active = False 376 self._status_var = tk.StringVar(value="Ready") 377 status_bar = ttk.Label(self, textvariable=self._status_var, relief=tk.SUNKEN, anchor=tk.W) 378 status_bar.pack(fill=tk.X, side=tk.BOTTOM, padx=5, pady=2) 379 380 # --- Settings Persistence --- 381 382 @staticmethod 383 def _load_settings() -> dict: 384 """Load app settings (last opened file, etc.).""" 385 return load_settings() 386 387 def _save_settings(self): 388 """Persist app settings.""" 389 try: 390 _settings_file.write_text(json.dumps(self._settings, indent=2)) 391 except OSError: 392 pass # Non-fatal: settings are convenience, not critical 393 394 def _get_initial_dir(self) -> str: 395 """Return the best initial directory for file dialogs.""" 396 # Use the directory of the current file if one is open 397 if self._current_file and self._current_file.parent.exists(): 398 return str(self._current_file.parent) 399 # Fall back to data/ in the repo 400 if _default_data_dir.exists(): 401 return str(_default_data_dir) 402 return str(_project_root) 403 404 # --- Config Management --- 405 406 def _new_config(self): 407 """Create a new blank configuration with two default controllers.""" 408 if self._dirty and not self._handle_unsaved_changes(): 409 return 410 411 self._config = FullConfig( 412 controllers={ 413 0: ControllerConfig(port=0, name="Driver"), 414 1: ControllerConfig(port=1, name="Operator"), 415 } 416 ) 417 self._current_file = None 418 self._dirty = False 419 self._undo_stack.clear() 420 self._redo_stack.clear() 421 self._clean_config = deepcopy(self._config) 422 self._sync_ui_from_config() 423 self._update_title() 424 self._status_var.set("New configuration created") 425 426 def _open_dialog(self): 427 if self._dirty and not self._handle_unsaved_changes(): 428 return 429 430 path = filedialog.askopenfilename( 431 title="Open Controller Config", 432 initialdir=self._get_initial_dir(), 433 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 434 ) 435 if path: 436 self._open_file(Path(path)) 437 438 def _open_file(self, path: Path): 439 try: 440 self._config = load_config(path) 441 self._current_file = path.resolve() 442 self._dirty = False 443 self._undo_stack.clear() 444 self._redo_stack.clear() 445 self._clean_config = deepcopy(self._config) 446 self._sync_ui_from_config() 447 self._update_title() 448 self._status_var.set(f"Opened: {path.name}") 449 self._settings["last_file"] = str(self._current_file) 450 self._save_settings() 451 # Warn if config version doesn't match expected version 452 from utils.controller.config_io import CONFIG_VERSION 453 if self._config.version and self._config.version != CONFIG_VERSION: 454 messagebox.showwarning( 455 "Version Mismatch", 456 f"Config file version '{self._config.version}' does not " 457 f"match expected version '{CONFIG_VERSION}'.\n\n" 458 "The file may have been created with a different " 459 "version of the tool.") 460 except Exception as e: 461 messagebox.showerror("Error", f"Failed to open file:\n{e}") 462 463 def _save(self): 464 if self._current_file: 465 self._save_to(self._current_file) 466 else: 467 self._save_as() 468 469 def _save_as(self): 470 path = filedialog.asksaveasfilename( 471 title="Save Controller Config", 472 initialdir=self._get_initial_dir(), 473 defaultextension=".yaml", 474 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 475 ) 476 if path: 477 self._save_to(Path(path)) 478 479 def _save_to(self, path: Path): 480 try: 481 self._sync_config_from_ui() 482 save_config(self._config, path) 483 self._current_file = path.resolve() 484 self._dirty = False 485 self._clean_config = deepcopy(self._config) 486 self._update_title() 487 self._status_var.set(f"Saved: {path.name}") 488 self._settings["last_file"] = str(self._current_file) 489 self._save_settings() 490 except Exception as e: 491 messagebox.showerror("Error", f"Failed to save file:\n{e}") 492 493 def _handle_unsaved_changes(self) -> bool: 494 """Prompt user to save/discard/cancel unsaved changes. 495 496 Returns True if the caller should proceed with its action 497 (changes were saved or discarded), False to abort. 498 """ 499 dialog = _UnsavedChangesDialog(self) 500 choice = dialog.result 501 502 if choice == "save": 503 self._save() 504 return not self._dirty # False if save was cancelled 505 elif choice == "save_as": 506 self._save_as() 507 return not self._dirty 508 elif choice == "discard": 509 return True 510 else: # cancel 511 return False 512 513 def _on_close(self): 514 if self._dirty: 515 if not self._handle_unsaved_changes(): 516 return 517 self._settings["geometry"] = self.geometry() 518 # Save active notebook tab 519 try: 520 self._settings["active_tab"] = self._notebook.index( 521 self._notebook.select()) 522 except Exception: 523 pass # Tab index unavailable during teardown 524 # Save Action Editor pane positions (skip if user just reset layout) 525 if not getattr(self, '_sash_reset', False): 526 try: 527 self._settings["editor_hsash"] = [ 528 self._action_editor._hpaned.sash_coord(i)[0] 529 for i in range(2)] 530 except Exception: 531 pass # Sash coords unavailable if panes not yet rendered 532 self._save_settings() 533 self.destroy() 534 535 def _restore_tab_state(self): 536 """Restore saved notebook tab selection.""" 537 saved_tab = self._settings.get("active_tab") 538 if saved_tab is not None: 539 try: 540 self._notebook.select(saved_tab) 541 except Exception: 542 pass # Saved tab index may be out of range 543 544 def _update_title(self): 545 name = self._current_file.name if self._current_file else "Untitled" 546 dirty = " *" if self._dirty else "" 547 self.title(f"FRC Controller Config - {name}{dirty}") 548 549 def _mark_dirty(self): 550 self._dirty = True 551 self._update_title() 552 self._action_panel.update_binding_tags() 553 554 def _is_config_clean(self) -> bool: 555 """Check whether current config matches the last saved/loaded state.""" 556 self._sync_config_from_ui() 557 return self._config == self._clean_config 558 559 # --- Undo / Redo --- 560 561 _UNDO_LIMIT = 50 562 563 def _take_snapshot(self) -> tuple[FullConfig, set[str]]: 564 """Capture current config + action panel empty groups.""" 565 self._sync_config_from_ui() 566 return (deepcopy(self._config), 567 self._action_panel.get_empty_groups()) 568 569 def _push_undo(self, coalesce_ms: int = 0): 570 """Snapshot current state onto the undo stack. 571 572 Args: 573 coalesce_ms: if > 0 and the last push was within this many ms, 574 replace the top of the stack instead of pushing a new entry. 575 Useful for coalescing rapid keystrokes in text fields. 576 """ 577 now = time.monotonic() 578 if coalesce_ms and self._undo_stack: 579 if (now - self._last_undo_time) < (coalesce_ms / 1000.0): 580 self._undo_stack[-1] = self._take_snapshot() 581 self._redo_stack.clear() 582 return 583 self._undo_stack.append(self._take_snapshot()) 584 if len(self._undo_stack) > self._UNDO_LIMIT: 585 self._undo_stack.pop(0) 586 self._redo_stack.clear() 587 self._last_undo_time = now 588 589 def _restore_snapshot(self, config: FullConfig, empty_groups: set[str], 590 restore_selection: str | None = None): 591 """Restore a config snapshot and re-sync the UI.""" 592 self._restoring = True 593 try: 594 self._config = config 595 # Merge legacy undo-stack empty_groups into config 596 self._config.empty_groups = ( 597 self._config.empty_groups | empty_groups) 598 self._sync_ui_from_config(restore_selection) 599 finally: 600 self._restoring = False 601 602 def _undo(self): 603 """Undo the last change.""" 604 if not self._undo_stack: 605 self._status_var.set("Nothing to undo") 606 return 607 selected = self._action_panel._selected_name 608 self._redo_stack.append(self._take_snapshot()) 609 config, empty_groups = self._undo_stack.pop() 610 self._restore_snapshot(config, empty_groups, selected) 611 self._dirty = not self._is_config_clean() 612 self._update_title() 613 self._status_var.set("Undo") 614 615 def _redo(self): 616 """Redo the last undone change.""" 617 if not self._redo_stack: 618 self._status_var.set("Nothing to redo") 619 return 620 selected = self._action_panel._selected_name 621 self._undo_stack.append(self._take_snapshot()) 622 config, empty_groups = self._redo_stack.pop() 623 self._restore_snapshot(config, empty_groups, selected) 624 self._dirty = not self._is_config_clean() 625 self._update_title() 626 self._status_var.set("Redo") 627 628 # --- UI <-> Config Sync --- 629 630 def _sync_ui_from_config(self, restore_selection: str | None = None): 631 """Push config data to all UI elements. 632 633 Args: 634 restore_selection: if provided, re-select this action after 635 rebuilding the tree (used by undo/redo to preserve context). 636 """ 637 # Update action panel 638 self._action_panel.set_actions(self._config.actions) 639 self._action_panel.set_empty_groups(self._config.empty_groups) 640 641 # Update controller tabs — reuse existing canvases when possible 642 new_ports = sorted(self._config.controllers.keys()) 643 old_ports = sorted(self._controller_canvases.keys()) 644 645 # The Action Editor tab is always at index 0; controller tabs follow 646 ctrl_tab_offset = 1 647 648 if new_ports == old_ports: 649 # Same controllers — just update bindings and tab labels in place 650 for idx, port in enumerate(new_ports): 651 ctrl = self._config.controllers[port] 652 self._controller_canvases[port].set_bindings(ctrl.bindings) 653 label = ctrl.name or f"Controller {port}" 654 self._notebook.tab( 655 idx + ctrl_tab_offset, 656 text=f"{label} (Port {port})") 657 else: 658 # Controller set changed — remove controller tabs (keep editor) 659 all_tabs = self._notebook.tabs() 660 for tab_id in all_tabs: 661 widget = self._notebook.nametowidget(tab_id) 662 if widget is not self._action_editor: 663 self._notebook.forget(tab_id) 664 self._controller_canvases.clear() 665 for port in new_ports: 666 ctrl = self._config.controllers[port] 667 self._create_controller_tab(port, ctrl) 668 669 # Restore selection or clear the Action Editor 670 if (restore_selection 671 and restore_selection in self._config.actions): 672 self._action_panel._reselect(restore_selection) 673 else: 674 self._action_editor.clear() 675 676 def _sync_config_from_ui(self): 677 """Pull current UI state back into the config.""" 678 self._config.actions = self._action_panel.get_actions() 679 self._config.empty_groups = self._action_panel.get_empty_groups() 680 681 def _create_controller_tab(self, port: int, ctrl: ControllerConfig): 682 """Create a tab for a controller.""" 683 tab_frame = ttk.Frame(self._notebook) 684 label = ctrl.name or f"Controller {port}" 685 self._notebook.add(tab_frame, text=f"{label} (Port {port})") 686 687 # Name editor at top of tab 688 name_frame = ttk.Frame(tab_frame) 689 name_frame.pack(fill=tk.X, padx=5, pady=5) 690 ttk.Label(name_frame, text="Controller Name:").pack(side=tk.LEFT) 691 name_var = tk.StringVar(value=ctrl.name) 692 name_entry = ttk.Entry(name_frame, textvariable=name_var, width=20) 693 name_entry.pack(side=tk.LEFT, padx=5) 694 695 def on_name_change(*args, p=port, v=name_var): 696 if self._restoring: 697 return 698 if p in self._config.controllers: 699 self._push_undo(coalesce_ms=500) 700 self._config.controllers[p].name = v.get() 701 # Update tab label (offset by 1 for Action Editor tab) 702 idx = sorted(self._config.controllers.keys()).index(p) 703 label_text = v.get() or f"Controller {p}" 704 self._notebook.tab( 705 idx + 1, text=f"{label_text} (Port {p})") 706 self._mark_dirty() 707 708 name_var.trace_add("write", on_name_change) 709 710 # Controller canvas 711 canvas = ControllerCanvas( 712 tab_frame, 713 on_binding_click=lambda input_name, p=port: self._on_binding_click(p, input_name), 714 on_binding_clear=lambda input_name, p=port: self._on_binding_clear(p, input_name), 715 on_mouse_coord=self._on_mouse_coord, 716 on_label_moved=self._on_label_moved, 717 on_hover_input=lambda input_name, p=port: self._on_hover_input(p, input_name), 718 on_hover_shape=lambda input_names, p=port: self._on_hover_shape(p, input_names), 719 on_action_remove=lambda input_name, action, p=port: self._on_action_remove(p, input_name, action), 720 label_positions=self._settings.get("label_positions", {}), 721 icon_loader=self._icon_loader, 722 ) 723 canvas.pack(fill=tk.BOTH, expand=True) 724 canvas.set_bindings(ctrl.bindings) 725 canvas.set_show_borders(self._show_borders_var.get()) 726 canvas.set_labels_locked(self._lock_labels_var.get()) 727 canvas.set_hide_unassigned(self._hide_unassigned_var.get()) 728 729 self._controller_canvases[port] = canvas 730 731 def _add_controller_tab(self): 732 """Add a new controller at the next available port.""" 733 self._push_undo() 734 existing_ports = set(self._config.controllers.keys()) 735 port = 0 736 while port in existing_ports: 737 port += 1 738 739 ctrl = ControllerConfig(port=port, name=f"Controller {port}") 740 self._config.controllers[port] = ctrl 741 self._create_controller_tab(port, ctrl) 742 self._mark_dirty() 743 744 # Select the new tab 745 self._notebook.select(len(self._notebook.tabs()) - 1) 746 747 def _remove_controller_tab(self): 748 """Remove the currently selected controller tab.""" 749 current = self._notebook.index(self._notebook.select()) 750 # Offset by 1 for the Action Editor tab at index 0 751 ctrl_idx = current - 1 752 ports = sorted(self._config.controllers.keys()) 753 if ctrl_idx < 0 or ctrl_idx >= len(ports): 754 return 755 port = ports[ctrl_idx] 756 757 if not messagebox.askyesno("Remove Controller", 758 f"Remove controller on port {port}?"): 759 return 760 761 self._push_undo() 762 del self._config.controllers[port] 763 if port in self._controller_canvases: 764 del self._controller_canvases[port] 765 self._notebook.forget(current) 766 self._mark_dirty() 767 768 # --- Callbacks --- 769 770 def _on_mouse_coord(self, img_x: int, img_y: int): 771 """Update status bar with mouse position in source image pixels.""" 772 # Don't overwrite action info while hovering a binding box 773 if not self._hover_status_active: 774 self._status_var.set(f"Image coords: ({img_x}, {img_y})") 775 776 def _format_action_status(self, port: int, input_names: list[str]) -> str | None: 777 """Build a status string for actions bound to the given inputs.""" 778 ctrl = self._config.controllers.get(port) 779 if not ctrl: 780 return None 781 782 parts = [] 783 for input_name in input_names: 784 for action_name in ctrl.bindings.get(input_name, []): 785 action = self._config.actions.get(action_name) 786 if action: 787 desc = action.description or "No description" 788 atype = action.input_type.value.capitalize() 789 parts.append(f"{action.qualified_name} ({atype}) - {desc}") 790 else: 791 parts.append(action_name) 792 return " | ".join(parts) if parts else None 793 794 def _on_hover_input(self, port: int, input_name: str | None): 795 """Update status bar with action info when hovering a binding box.""" 796 if not input_name: 797 self._hover_status_active = False 798 self._status_var.set("Ready") 799 return 800 801 text = self._format_action_status(port, [input_name]) 802 if text: 803 self._hover_status_active = True 804 self._status_var.set(text) 805 else: 806 self._hover_status_active = False 807 808 def _on_hover_shape(self, port: int, input_names: list[str] | None): 809 """Update status bar with action info when hovering a controller shape.""" 810 if not input_names: 811 self._hover_status_active = False 812 self._status_var.set("Ready") 813 return 814 815 text = self._format_action_status(port, input_names) 816 if text: 817 self._hover_status_active = True 818 self._status_var.set(text) 819 else: 820 self._hover_status_active = False 821 822 def _toggle_borders(self): 823 """Toggle shape border visibility on all canvases.""" 824 show = self._show_borders_var.get() 825 for canvas in self._controller_canvases.values(): 826 canvas.set_show_borders(show) 827 self._settings["show_borders"] = show 828 self._save_settings() 829 830 def _toggle_lock_labels(self): 831 """Toggle label dragging lock on all canvases.""" 832 locked = self._lock_labels_var.get() 833 for canvas in self._controller_canvases.values(): 834 canvas.set_labels_locked(locked) 835 self._status_var.set( 836 "Label positions locked" if locked else "Label positions unlocked") 837 838 def _toggle_hide_unassigned(self): 839 """Toggle hiding of unassigned inputs on all canvases.""" 840 hide = self._hide_unassigned_var.get() 841 for canvas in self._controller_canvases.values(): 842 canvas.set_hide_unassigned(hide) 843 self._status_var.set( 844 "Unassigned inputs hidden" if hide 845 else "Unassigned inputs shown") 846 847 def _on_advanced_changed(self): 848 """Notify children when Advanced menu toggles change.""" 849 self._action_panel.on_advanced_changed() 850 self._action_editor.on_advanced_changed() 851 852 def _toggle_edit_details(self): 853 """Toggle whether the Action Details panel fields are editable.""" 854 enabled = self._edit_details_var.get() 855 self._action_panel.set_details_editable(enabled) 856 self._settings["edit_details"] = enabled 857 self._save_settings() 858 859 def get_advanced_flags(self) -> dict: 860 """Return current advanced feature flags (session-only).""" 861 return { 862 "splines": self._adv_splines_var.get(), 863 "nonmono": self._adv_nonmono_var.get(), 864 } 865 866 def _show_about(self): 867 """Show the About dialog with license information.""" 868 about = tk.Toplevel(self) 869 about.title("About") 870 about.resizable(True, True) 871 about.transient(self) 872 about.grab_set() 873 874 # Read license files 875 proj_license = "" 876 img_license = "" 877 try: 878 lf = _project_root / "LICENSE" 879 if lf.exists(): 880 proj_license = lf.read_text(encoding="utf-8") 881 except OSError: 882 proj_license = "(Could not read LICENSE file)" 883 try: 884 lf = _project_root / "images" / "LICENSE.md" 885 if lf.exists(): 886 img_license = lf.read_text(encoding="utf-8") 887 except OSError: 888 img_license = "(Could not read images/LICENSE.md)" 889 890 sep = "=" * 40 891 content = ( 892 "Raptacon Controller Config\n" 893 "FRC Team 3200\n" 894 "\n" 895 + proj_license.strip() + "\n\n" 896 + sep + "\n" 897 + "Image Licenses\n" 898 + sep + "\n\n" 899 + img_license.strip() + "\n" 900 ) 901 902 text = tk.Text(about, wrap=tk.NONE, width=60, height=25, 903 font=("TkDefaultFont", 9)) 904 scroll = ttk.Scrollbar(about, orient=tk.VERTICAL, 905 command=text.yview) 906 text.configure(yscrollcommand=scroll.set) 907 text.insert("1.0", content) 908 text.configure(state="disabled") 909 scroll.pack(side=tk.RIGHT, fill=tk.Y) 910 text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 911 912 ttk.Button(about, text="Close", 913 command=about.destroy).pack(pady=(0, 8)) 914 915 # Center on parent 916 about.update_idletasks() 917 pw, ph = self.winfo_width(), self.winfo_height() 918 px, py = self.winfo_x(), self.winfo_y() 919 w, h = about.winfo_width(), about.winfo_height() 920 about.geometry(f"+{px + (pw - w) // 2}+{py + (ph - h) // 2}") 921 922 def _reset_label_positions(self): 923 """Reset all dragged label positions to defaults.""" 924 self._settings.pop("label_positions", None) 925 self._save_settings() 926 for canvas in self._controller_canvases.values(): 927 canvas.reset_label_positions() 928 self._status_var.set("Label positions reset to defaults") 929 930 def _reset_gui_layout(self): 931 """Reset GUI layout settings (geometry, tabs, panes) but keep labels.""" 932 for key in ("geometry", "active_tab", "editor_hsash", "show_borders"): 933 self._settings.pop(key, None) 934 self._sash_reset = True # prevent _on_close from re-saving 935 self._save_settings() 936 937 # Reset window geometry 938 self.geometry("1200x700") 939 940 # Reset show-borders toggle 941 self._show_borders_var.set(False) 942 for canvas in self._controller_canvases.values(): 943 canvas.set_show_borders(False) 944 945 # Reset Action Editor sash to equal thirds 946 self._action_editor._sash_applied = False 947 self._action_editor._saved_sash = None 948 try: 949 w = self._action_editor._hpaned.winfo_width() 950 if w > 50: 951 third = w // 3 952 self._action_editor._hpaned.sash_place(0, third, 0) 953 self._action_editor._hpaned.sash_place(1, third * 2, 0) 954 self._action_editor._sash_applied = True 955 except Exception: 956 pass # Pane not yet mapped; sash will be set on next resize 957 958 # Switch to first tab (Action Editor) 959 self._notebook.select(0) 960 self._status_var.set("GUI layout reset to defaults") 961 962 def _on_label_moved(self, input_name: str, img_x: int, img_y: int): 963 """Persist a dragged label position to settings.""" 964 positions = self._settings.setdefault("label_positions", {}) 965 positions[input_name] = [img_x, img_y] 966 self._save_settings() 967 968 # --- Drag-and-Drop (action panel → controller canvas) --- 969 970 def _on_drag_start(self, action_qname: str): 971 """Called when an action drag begins from the action panel.""" 972 self._drag_action = action_qname 973 self._status_var.set(f"Dragging: {action_qname}") 974 self.config(cursor="plus") 975 for c in self._controller_canvases.values(): 976 c.set_drag_cursor(True) 977 # Temporarily show all inputs so user can see all drop targets 978 if self._hide_unassigned_var.get(): 979 for c in self._controller_canvases.values(): 980 c.set_hide_unassigned(False) 981 # Grey out incompatible inputs 982 compatible = self._get_compatible_inputs(action_qname) 983 for c in self._controller_canvases.values(): 984 c.dim_incompatible_inputs(compatible) 985 # Bind global handlers to track drag across widgets 986 self.bind_all("<B1-Motion>", self._on_drag_motion, add="+") 987 self.bind_all("<ButtonRelease-1>", self._on_drag_release, add="+") 988 989 def _on_drag_end(self): 990 """Called when the tree releases the mouse (safety cleanup).""" 991 self._drag_cleanup() 992 993 def _on_drag_motion(self, event): 994 """Track drag across widgets, highlighting drop targets.""" 995 if not self._drag_action: 996 return 997 998 canvas = self._find_canvas_at(event.x_root, event.y_root) 999 1000 # Clear highlights on all canvases except the one under cursor 1001 for c in self._controller_canvases.values(): 1002 if c is not canvas: 1003 c.clear_drop_highlight() 1004 1005 if canvas: 1006 input_name = canvas.highlight_drop_target( 1007 event.x_root, event.y_root) 1008 if input_name: 1009 inp = XBOX_INPUT_MAP.get(input_name) 1010 display = inp.display_name if inp else input_name 1011 self._status_var.set( 1012 f"Drop to bind: {self._drag_action} \u2192 {display}") 1013 else: 1014 self._status_var.set(f"Dragging: {self._drag_action}") 1015 else: 1016 self._status_var.set(f"Dragging: {self._drag_action}") 1017 1018 def _on_drag_release(self, event): 1019 """Handle drop onto controller canvas.""" 1020 action = self._drag_action 1021 if not action: 1022 self._drag_cleanup() 1023 return 1024 1025 canvas = self._find_canvas_at(event.x_root, event.y_root) 1026 port = self._port_for_canvas(canvas) if canvas else None 1027 1028 if canvas and port is not None: 1029 input_name, shape = canvas.get_drop_target( 1030 event.x_root, event.y_root) 1031 1032 if input_name: 1033 self._bind_dropped_action(port, input_name, action) 1034 elif shape: 1035 # Multi-input shape: show picker menu 1036 self._show_drop_input_menu( 1037 event, port, shape, action) 1038 # Full cleanup after menu closes (selection or dismiss) 1039 self._drag_cleanup() 1040 return 1041 1042 self._drag_cleanup() 1043 1044 def _check_type_compatible(self, action_qname: str, 1045 input_name: str) -> bool: 1046 """Check if an action's type is compatible with a controller input. 1047 1048 Shows a warning messagebox if incompatible. 1049 Returns True if compatible, False otherwise. 1050 """ 1051 action_def = self._config.actions.get(action_qname) 1052 inp = XBOX_INPUT_MAP.get(input_name) 1053 if not action_def or not inp: 1054 return True # Can't validate, allow it 1055 1056 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1057 if allowed is None: 1058 return True # Unknown input type, allow it 1059 1060 if action_def.input_type in allowed: 1061 return True 1062 1063 # Type mismatch — show warning popup 1064 action_type = action_def.input_type.value.capitalize() 1065 hint = _INPUT_TYPE_DESCRIPTION.get(inp.input_type, "") 1066 messagebox.showwarning( 1067 "Type Mismatch", 1068 f"Cannot bind '{action_qname}' ({action_type}) " 1069 f"to '{inp.display_name}' ({inp.input_type}).\n\n" 1070 f"{hint}.", 1071 ) 1072 return False 1073 1074 def _get_compatible_actions(self, input_name: str) -> list[str]: 1075 """Return action qualified names compatible with the given input.""" 1076 inp = XBOX_INPUT_MAP.get(input_name) 1077 if not inp: 1078 return list(self._config.actions.keys()) 1079 1080 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1081 if allowed is None: 1082 return list(self._config.actions.keys()) 1083 1084 return [ 1085 qname for qname, action_def in self._config.actions.items() 1086 if action_def.input_type in allowed 1087 ] 1088 1089 def _get_compatible_inputs(self, action_qname: str) -> set[str]: 1090 """Return the set of input names compatible with the given action.""" 1091 action_def = self._config.actions.get(action_qname) 1092 if not action_def: 1093 return {inp.name for inp in XBOX_INPUT_MAP.values()} 1094 compatible = set() 1095 for inp in XBOX_INPUT_MAP.values(): 1096 allowed = _COMPAT_ACTION_TYPES.get(inp.input_type) 1097 if allowed is None or action_def.input_type in allowed: 1098 compatible.add(inp.name) 1099 return compatible 1100 1101 def _get_binding_info_for_action( 1102 self, qname: str 1103 ) -> list[tuple[str, str, str]]: 1104 """Return list of (controller_name, input_display, input_name). 1105 1106 Used by ActionPanel for tooltips, color-coding, and icons. 1107 """ 1108 result = [] 1109 for port, ctrl in self._config.controllers.items(): 1110 ctrl_label = ctrl.name or f"Controller {port}" 1111 for input_name, actions in ctrl.bindings.items(): 1112 if qname in actions: 1113 inp = XBOX_INPUT_MAP.get(input_name) 1114 display = inp.display_name if inp else input_name 1115 result.append((ctrl_label, display, input_name)) 1116 return result 1117 1118 def _get_all_group_names(self) -> list[str]: 1119 """Delegate to ActionPanel — single source of truth for group names.""" 1120 return self._action_panel.get_group_names() 1121 1122 def _get_all_controllers(self) -> list[tuple[int, str]]: 1123 """Return list of (port, controller_name) for the context menu.""" 1124 return [ 1125 (port, ctrl.name or f"Controller {port}") 1126 for port, ctrl in sorted(self._config.controllers.items()) 1127 ] 1128 1129 def _get_compatible_inputs_with_display( 1130 self, qname: str) -> list[tuple[str, str]]: 1131 """Return list of (input_name, display_name) compatible with action.""" 1132 compatible_names = self._get_compatible_inputs(qname) 1133 result = [] 1134 for inp in XBOX_INPUT_MAP.values(): 1135 if inp.name in compatible_names: 1136 result.append((inp.name, inp.display_name)) 1137 return result 1138 1139 def _is_action_bound_to(self, qname: str, port: int, 1140 input_name: str) -> bool: 1141 """Check if action is bound to a specific input on a controller.""" 1142 ctrl = self._config.controllers.get(port) 1143 if not ctrl: 1144 return False 1145 return qname in ctrl.bindings.get(input_name, []) 1146 1147 def _context_assign_action(self, qname: str, port: int, 1148 input_name: str): 1149 """Assign an action to an input from the context menu.""" 1150 self._bind_dropped_action(port, input_name, qname) 1151 1152 def _context_unassign_action(self, qname: str, port: int, 1153 input_name: str): 1154 """Unassign an action from an input via the context menu.""" 1155 self._on_action_remove(port, input_name, qname) 1156 1157 def _context_unassign_all(self, qname: str): 1158 """Remove an action from all inputs on all controllers.""" 1159 self._push_undo() 1160 changed = False 1161 for port, ctrl in self._config.controllers.items(): 1162 for input_name in list(ctrl.bindings.keys()): 1163 actions = ctrl.bindings[input_name] 1164 if qname in actions: 1165 actions.remove(qname) 1166 changed = True 1167 if not actions: 1168 del ctrl.bindings[input_name] 1169 canvas = self._controller_canvases.get(port) 1170 if canvas: 1171 canvas.set_bindings(ctrl.bindings) 1172 if changed: 1173 self._mark_dirty() 1174 self._action_editor.refresh_bindings() 1175 self._status_var.set(f"Removed {qname} from all inputs") 1176 1177 def _bind_dropped_action(self, port: int, input_name: str, action: str): 1178 """Add an action binding from a drag-and-drop, preventing duplicates.""" 1179 ctrl = self._config.controllers.get(port) 1180 if not ctrl: 1181 return 1182 1183 inp = XBOX_INPUT_MAP.get(input_name) 1184 display = inp.display_name if inp else input_name 1185 1186 # Type compatibility check 1187 if not self._check_type_compatible(action, input_name): 1188 return 1189 1190 current = ctrl.bindings.get(input_name, []) 1191 if action in current: 1192 self._status_var.set( 1193 f"{action} already bound to {display}") 1194 return 1195 1196 self._push_undo() 1197 ctrl.bindings.setdefault(input_name, []).append(action) 1198 canvas = self._controller_canvases.get(port) 1199 if canvas: 1200 canvas.set_bindings(ctrl.bindings) 1201 self._mark_dirty() 1202 self._action_editor.refresh_bindings() 1203 self._status_var.set(f"Bound {action} \u2192 {display}") 1204 1205 def _show_drop_input_menu(self, event, port: int, shape, action: str): 1206 """Show menu to pick which input of a multi-input shape to bind to.""" 1207 menu = tk.Menu(self, tearoff=0) 1208 for input_name in shape.inputs: 1209 inp = XBOX_INPUT_MAP.get(input_name) 1210 display = inp.display_name if inp else input_name 1211 menu.add_command( 1212 label=display, 1213 command=lambda n=input_name: self._bind_dropped_action( 1214 port, n, action), 1215 ) 1216 menu.tk_popup(event.x_root, event.y_root) 1217 1218 def _find_canvas_at(self, x_root: int, y_root: int): 1219 """Find the ControllerCanvas widget under the given root coordinates.""" 1220 widget = self.winfo_containing(x_root, y_root) 1221 while widget: 1222 if isinstance(widget, ControllerCanvas): 1223 return widget 1224 widget = getattr(widget, 'master', None) 1225 return None 1226 1227 def _port_for_canvas(self, canvas: ControllerCanvas) -> int | None: 1228 """Return the port number for a given canvas widget.""" 1229 for port, c in self._controller_canvases.items(): 1230 if c is canvas: 1231 return port 1232 return None 1233 1234 def _unbind_drag_handlers(self): 1235 """Remove global drag event handlers.""" 1236 self.unbind_all("<B1-Motion>") 1237 self.unbind_all("<ButtonRelease-1>") 1238 1239 def _drag_cleanup(self): 1240 """Reset all drag state.""" 1241 self._drag_action = None 1242 self._unbind_drag_handlers() 1243 self.config(cursor="") 1244 for c in self._controller_canvases.values(): 1245 c.set_drag_cursor(False) 1246 for c in self._controller_canvases.values(): 1247 c.clear_drop_highlight() 1248 c.clear_dim_overlays() 1249 # Restore hide-unassigned state after drag 1250 if self._hide_unassigned_var.get(): 1251 for c in self._controller_canvases.values(): 1252 c.set_hide_unassigned(True) 1253 if not self._hover_status_active: 1254 self._status_var.set("Ready") 1255 1256 def _on_before_action_change(self, coalesce_ms: int): 1257 """Called by ActionPanel BEFORE it mutates actions (for undo snapshot).""" 1258 if self._restoring: 1259 return 1260 self._push_undo(coalesce_ms=coalesce_ms) 1261 1262 def _on_action_selection_changed(self, qname: str | None): 1263 """Sync Action Editor tab when tree selection changes.""" 1264 if qname: 1265 action = self._config.actions.get(qname) 1266 if action: 1267 self._action_editor.load_action(action, qname) 1268 return 1269 self._action_editor.clear() 1270 1271 def _on_action_editor_changed(self): 1272 """Sync sidebar and mark dirty when Action Editor edits a field.""" 1273 if self._restoring: 1274 return 1275 1276 # Detect name/group change: the editor's qname is the old key, 1277 # but the action object already has the new name/group. 1278 old_qname = self._action_editor._qname 1279 action = self._action_editor._action 1280 if old_qname and action: 1281 new_qname = action.qualified_name 1282 if new_qname != old_qname: 1283 if self._action_panel.rename_action(old_qname, new_qname): 1284 # Update the editor's tracked qname to match 1285 self._action_editor._qname = new_qname 1286 else: 1287 # Rename rejected (duplicate) — revert the action object 1288 parts = old_qname.split(".", 1) 1289 action.group = parts[0] 1290 action.name = parts[1] if len(parts) > 1 else parts[0] 1291 self._action_editor.load_action(action, old_qname) 1292 return 1293 1294 self._config.actions = self._action_panel.get_actions() 1295 # Reload the sidebar detail form to show updated values 1296 selected = self._action_panel._selected_name 1297 if selected: 1298 self._action_panel._load_detail(selected) 1299 self._mark_dirty() 1300 # Refresh controller canvases in case bindings changed 1301 for port, canvas in self._controller_canvases.items(): 1302 ctrl = self._config.controllers.get(port) 1303 if ctrl: 1304 canvas.set_bindings(ctrl.bindings) 1305 1306 def _on_actions_changed(self): 1307 """Called when actions are added/removed/modified in the action panel.""" 1308 if self._restoring: 1309 return 1310 self._config.actions = self._action_panel.get_actions() 1311 self._mark_dirty() 1312 self._check_orphan_bindings() 1313 # Sync Action Editor tab 1314 selected = self._action_panel._selected_name 1315 if selected: 1316 action = self._config.actions.get(selected) 1317 if action: 1318 self._action_editor.load_action(action, selected) 1319 return 1320 self._action_editor.clear() 1321 1322 def _check_orphan_bindings(self): 1323 """Detect and offer to remove bindings referencing deleted actions.""" 1324 orphans = [] 1325 for port, ctrl in self._config.controllers.items(): 1326 ctrl_label = ctrl.name or f"Controller {port}" 1327 for input_name, actions in ctrl.bindings.items(): 1328 for qname in actions: 1329 if qname not in self._config.actions: 1330 inp = XBOX_INPUT_MAP.get(input_name) 1331 display = inp.display_name if inp else input_name 1332 orphans.append((port, input_name, qname, 1333 ctrl_label, display)) 1334 if not orphans: 1335 return 1336 1337 lines = [f" {o[3]} / {o[4]}: {o[2]}" for o in orphans] 1338 detail = "\n".join(lines) 1339 msg = ( 1340 "The following bindings reference actions that no " 1341 f"longer exist:\n\n{detail}" 1342 "\n\nRemove these orphaned bindings?" 1343 ) 1344 if messagebox.askyesno("Orphaned Bindings", msg, parent=self): 1345 for port, input_name, qname, _, _ in orphans: 1346 ctrl = self._config.controllers.get(port) 1347 if not ctrl: 1348 continue 1349 actions = ctrl.bindings.get(input_name, []) 1350 if qname in actions: 1351 actions.remove(qname) 1352 if not actions and input_name in ctrl.bindings: 1353 del ctrl.bindings[input_name] 1354 # Refresh canvases 1355 for port, ctrl in self._config.controllers.items(): 1356 canvas = self._controller_canvases.get(port) 1357 if canvas: 1358 canvas.set_bindings(ctrl.bindings) 1359 self._status_var.set( 1360 f"Removed {len(orphans)} orphaned binding(s)") 1361 1362 def _on_action_renamed(self, old_qname: str, new_qname: str): 1363 """Update all binding references when an action's qualified name changes.""" 1364 for port, ctrl in self._config.controllers.items(): 1365 changed = False 1366 for input_name, actions in ctrl.bindings.items(): 1367 if old_qname in actions: 1368 idx = actions.index(old_qname) 1369 actions[idx] = new_qname 1370 changed = True 1371 if changed: 1372 canvas = self._controller_canvases.get(port) 1373 if canvas: 1374 canvas.set_bindings(ctrl.bindings) 1375 1376 def _on_binding_clear(self, port: int, input_name: str): 1377 """Clear all bindings for a specific input.""" 1378 ctrl = self._config.controllers.get(port) 1379 if not ctrl: 1380 return 1381 if input_name in ctrl.bindings: 1382 self._push_undo() 1383 del ctrl.bindings[input_name] 1384 canvas = self._controller_canvases.get(port) 1385 if canvas: 1386 canvas.set_bindings(ctrl.bindings) 1387 self._mark_dirty() 1388 self._action_editor.refresh_bindings() 1389 1390 def _on_action_remove(self, port: int, input_name: str, action: str): 1391 """Remove a single action from an input's bindings.""" 1392 ctrl = self._config.controllers.get(port) 1393 if not ctrl: 1394 return 1395 actions = ctrl.bindings.get(input_name, []) 1396 if action in actions: 1397 self._push_undo() 1398 actions.remove(action) 1399 if not actions: 1400 del ctrl.bindings[input_name] 1401 canvas = self._controller_canvases.get(port) 1402 if canvas: 1403 canvas.set_bindings(ctrl.bindings) 1404 self._mark_dirty() 1405 self._action_editor.refresh_bindings() 1406 self._status_var.set(f"Removed {action} from {input_name}") 1407 1408 def _on_binding_click(self, port: int, input_name: str): 1409 """Open the binding dialog for a specific input on a specific controller.""" 1410 ctrl = self._config.controllers.get(port) 1411 if not ctrl: 1412 return 1413 1414 current_actions = ctrl.bindings.get(input_name, []) 1415 # Only show actions whose type is compatible with this input 1416 available_actions = self._get_compatible_actions(input_name) 1417 1418 # Build description map for the dialog 1419 descriptions = { 1420 qname: act.description 1421 for qname, act in self._config.actions.items() 1422 if act.description 1423 } 1424 1425 dialog = BindingDialog(self, input_name, current_actions, 1426 available_actions, descriptions) 1427 result = dialog.get_result() 1428 1429 canvas = self._controller_canvases.get(port) 1430 1431 if result is not None: 1432 self._push_undo() 1433 if result: 1434 ctrl.bindings[input_name] = result 1435 elif input_name in ctrl.bindings: 1436 del ctrl.bindings[input_name] 1437 1438 # Refresh the canvas 1439 if canvas: 1440 canvas.set_bindings(ctrl.bindings) 1441 self._mark_dirty() 1442 self._action_editor.refresh_bindings() 1443 1444 # Clear selection so line returns to default color 1445 if canvas: 1446 canvas.clear_selection() 1447 1448 # --- Import / Export --- 1449 1450 def _import_actions(self): 1451 """Import actions from another YAML file, merging with current config.""" 1452 path = filedialog.askopenfilename( 1453 title="Import Actions From...", 1454 initialdir=self._get_initial_dir(), 1455 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1456 ) 1457 if not path: 1458 return 1459 1460 try: 1461 imported = load_actions_from_file(Path(path)) 1462 except Exception as e: 1463 messagebox.showerror("Import Error", f"Failed to load file:\n{e}") 1464 return 1465 1466 if not imported: 1467 messagebox.showinfo("Import", "No actions found in the selected file.") 1468 return 1469 1470 current = self._action_panel.get_actions() 1471 1472 # Separate conflicts from non-conflicts 1473 conflicts = {qname for qname in imported if qname in current} 1474 non_conflicts = {qname: action for qname, action in imported.items() 1475 if qname not in conflicts} 1476 1477 # Resolve conflicts via dialog 1478 resolved = {} 1479 if conflicts: 1480 dialog = ImportConflictDialog(self, conflicts, current, imported) 1481 result = dialog.get_result() 1482 if result is None: 1483 return # User canceled 1484 resolved = result 1485 1486 # Merge 1487 self._push_undo() 1488 merged = dict(current) 1489 merged.update(non_conflicts) 1490 merged.update(resolved) 1491 1492 self._restoring = True # Prevent _on_actions_changed from pushing 1493 self._action_panel.set_actions(merged) 1494 self._restoring = False 1495 self._config.actions = self._action_panel.get_actions() 1496 self._mark_dirty() 1497 1498 count = len(non_conflicts) + len(resolved) 1499 self._status_var.set( 1500 f"Imported {count} action(s) from {Path(path).name}") 1501 1502 def _export_group(self, group_name: str): 1503 """Export a single group's actions to a YAML file.""" 1504 self._sync_config_from_ui() 1505 1506 group_actions = { 1507 qname: action 1508 for qname, action in self._config.actions.items() 1509 if action.group == group_name 1510 } 1511 1512 if not group_actions: 1513 messagebox.showinfo("Export", f"Group '{group_name}' has no actions.") 1514 return 1515 1516 path = filedialog.asksaveasfilename( 1517 title=f"Export Group: {group_name}", 1518 initialdir=self._get_initial_dir(), 1519 initialfile=f"{group_name}.yaml", 1520 defaultextension=".yaml", 1521 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1522 ) 1523 if not path: 1524 return 1525 1526 try: 1527 save_actions_to_file(group_actions, Path(path)) 1528 self._status_var.set( 1529 f"Exported group '{group_name}' to {Path(path).name}") 1530 except Exception as e: 1531 messagebox.showerror("Export Error", f"Failed to export:\n{e}") 1532 1533 def _export_all_groups(self): 1534 """Export each group as a separate YAML file in a chosen directory.""" 1535 self._sync_config_from_ui() 1536 1537 if not self._config.actions: 1538 messagebox.showinfo("Export", "No actions to export.") 1539 return 1540 1541 directory = filedialog.askdirectory( 1542 title="Export All Groups To...", 1543 initialdir=self._get_initial_dir(), 1544 ) 1545 if not directory: 1546 return 1547 1548 # Group actions by group name 1549 groups: dict[str, dict[str, object]] = {} 1550 for qname, action in self._config.actions.items(): 1551 groups.setdefault(action.group, {})[qname] = action 1552 1553 try: 1554 for group_name, group_actions in groups.items(): 1555 out_path = Path(directory) / f"{group_name}.yaml" 1556 save_actions_to_file(group_actions, out_path) 1557 1558 self._status_var.set( 1559 f"Exported {len(groups)} group(s) to {directory}") 1560 except Exception as e: 1561 messagebox.showerror("Export Error", f"Failed to export:\n{e}") 1562 1563 def _print_export(self, orientation: str, fmt: str): 1564 """Export controller layouts as PNG or PDF.""" 1565 self._sync_config_from_ui() 1566 1567 if not self._config.controllers: 1568 messagebox.showinfo("Export", "No controllers to export.") 1569 return 1570 1571 ext = f".{fmt}" 1572 filetypes = ( 1573 [("PNG files", "*.png"), ("All files", "*.*")] if fmt == "png" 1574 else [("PDF files", "*.pdf"), ("All files", "*.*")] 1575 ) 1576 path = filedialog.asksaveasfilename( 1577 title=f"Export {orientation.title()} {fmt.upper()}", 1578 initialdir=self._get_initial_dir(), 1579 initialfile=f"controllers_{orientation}{ext}", 1580 defaultextension=ext, 1581 filetypes=filetypes, 1582 ) 1583 if not path: 1584 return 1585 1586 try: 1587 label_positions = self._settings.get("label_positions", {}) 1588 export_pages(self._config, orientation, Path(path), 1589 label_positions, 1590 self._hide_unassigned_var.get(), 1591 self._icon_loader) 1592 self._status_var.set( 1593 f"Exported {orientation} {fmt.upper()} to {Path(path).name}") 1594 except Exception as e: 1595 messagebox.showerror("Export Error", 1596 f"Failed to export:\n{e}") 1597 1598 def _export_assignments(self): 1599 """Export controller assignments (no actions) to a YAML file.""" 1600 self._sync_config_from_ui() 1601 1602 if not self._config.controllers: 1603 messagebox.showinfo("Export", "No controllers to export.") 1604 return 1605 1606 path = filedialog.asksaveasfilename( 1607 title="Export Assignments", 1608 initialdir=self._get_initial_dir(), 1609 initialfile="assignments.yaml", 1610 defaultextension=".yaml", 1611 filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], 1612 ) 1613 if not path: 1614 return 1615 1616 try: 1617 save_assignments_to_file(self._config.controllers, Path(path)) 1618 self._status_var.set(f"Exported assignments to {Path(path).name}") 1619 except Exception as e: 1620 messagebox.showerror("Export Error", f"Failed to export:\n{e}")
Main application window.
ControllerConfigApp(initial_file: str | None = None)
140 def __init__(self, initial_file: str | None = None): 141 # Set app ID so Windows taskbar shows our icon instead of python.exe 142 try: 143 import ctypes 144 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( 145 "raptacon.controller_config") 146 except (AttributeError, OSError): 147 pass # Not on Windows or missing API 148 149 super().__init__() 150 self.title("FRC Controller Configuration") 151 self.geometry("1200x700") 152 self.minsize(900, 550) 153 154 self._config = FullConfig() 155 self._current_file: Path | None = None 156 self._dirty = False 157 self._settings = self._load_settings() 158 159 # Restore saved window geometry (size + position) 160 saved_geom = self._settings.get("geometry") 161 if saved_geom: 162 self.geometry(saved_geom) 163 164 # Window icon (title bar + taskbar) 165 icon_path = _project_root / "images" / "Raptacon3200-BG-BW.png" 166 if icon_path.exists(): 167 self._icon_image = tk.PhotoImage(file=str(icon_path)) 168 self.iconphoto(True, self._icon_image) 169 170 # Undo / redo stacks: each entry is (FullConfig, empty_groups_set) 171 self._undo_stack: list[tuple[FullConfig, set[str]]] = [] 172 self._redo_stack: list[tuple[FullConfig, set[str]]] = [] 173 self._last_undo_time: float = 0.0 174 self._restoring: bool = False # Guard against spurious pushes 175 # Snapshot of config at last save/load for accurate dirty tracking 176 self._clean_config: FullConfig = deepcopy(self._config) 177 178 # Drag-and-drop state 179 self._drag_action: str | None = None 180 self._drag_bindings_saved: dict = {} # saved bind_all IDs 181 182 # Icon loader for Xbox controller button icons 183 icons_dir = _project_root / "images" / "XboxControlIcons" / "Buttons Full Solid" 184 self._icon_loader = InputIconLoader(icons_dir, root=self) 185 186 self._build_menu() 187 self._build_layout() 188 189 # Load initial file, last opened file, or set up defaults 190 if initial_file: 191 self._open_file(Path(initial_file)) 192 elif self._settings.get("last_file"): 193 last = Path(self._settings["last_file"]) 194 if last.exists(): 195 self._open_file(last) 196 else: 197 self._new_config() 198 else: 199 self._new_config() 200 201 self._update_title() 202 self.protocol("WM_DELETE_WINDOW", self._on_close) 203 204 # Pre-load saved sash positions so they're ready when <Map> fires, 205 # then restore the active tab after idle (needs geometry). 206 saved_sash = self._settings.get("editor_hsash") 207 if saved_sash and len(saved_sash) >= 2: 208 self._action_editor.set_sash_positions(saved_sash) 209 self.after_idle(self._restore_tab_state)
Return a new top level widget on screen SCREENNAME. A new Tcl interpreter will be created. BASENAME will be used for the identification of the profile file (see readprofile). It is constructed from sys.argv[0] without extensions if None is given. CLASSNAME is the name of the widget class.
def
get_advanced_flags(self) -> dict:
859 def get_advanced_flags(self) -> dict: 860 """Return current advanced feature flags (session-only).""" 861 return { 862 "splines": self._adv_splines_var.get(), 863 "nonmono": self._adv_nonmono_var.get(), 864 }
Return current advanced feature flags (session-only).