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