host.controller_config.controller_canvas

Canvas widget that renders the Xbox controller image with binding overlays.

Draws leader lines from each controller input to binding boxes showing assigned actions. Bold outlines are drawn around each button on the controller image and are clickable to assign bindings. Binding boxes are draggable — custom positions persist across sessions.

   1"""Canvas widget that renders the Xbox controller image with binding overlays.
   2
   3Draws leader lines from each controller input to binding boxes showing
   4assigned actions. Bold outlines are drawn around each button on the
   5controller image and are clickable to assign bindings.  Binding boxes
   6are draggable — custom positions persist across sessions.
   7"""
   8
   9import io
  10import math
  11import sys
  12import tkinter as tk
  13from pathlib import Path
  14
  15from PIL import Image, ImageDraw, ImageTk
  16
  17from .layout_coords import (
  18    XBOX_INPUTS, XBOX_SHAPES, InputCoord, ButtonShape, _IMG_W, _IMG_H,
  19)
  20
  21# Try cairosvg for crisp SVG rendering, fall back to pre-rendered PNG
  22try:
  23    import cairosvg
  24    HAS_CAIROSVG = True
  25except (ImportError, OSError):
  26    HAS_CAIROSVG = False
  27
  28# Reference visual constants (at image scale 1.0, i.e. rendered_h == _IMG_H)
  29# Actual sizes are computed per-redraw by _compute_scaled_sizes().
  30_REF_BOX_WIDTH = 220
  31_REF_BOX_HEIGHT = 40
  32_REF_BOX_PAD = 6
  33_REF_LABEL_FONT = 12
  34_REF_ACTION_FONT = 14
  35_REF_PLUS_FONT = 28
  36_REF_LABEL_Y = 22       # y offset for action text below label
  37_REF_ACTION_STEP = 28   # y step per action line
  38
  39# Defaults used before first redraw
  40BOX_WIDTH = _REF_BOX_WIDTH
  41BOX_HEIGHT = _REF_BOX_HEIGHT
  42BOX_PAD = _REF_BOX_PAD
  43LINE_COLOR = "#555555"
  44LINE_SELECTED_COLOR = "#cc0000"
  45BOX_OUTLINE = "#888888"
  46BOX_FILL = "#f0f0f0"
  47BOX_FILL_HOVER = "#ddeeff"
  48BOX_FILL_ASSIGNED = "#d4edda"
  49UNASSIGNED_TEXT = "(unassigned)"
  50UNASSIGNED_COLOR = "#999999"
  51ASSIGNED_COLOR = "#222222"
  52AXIS_INDICATOR_COLORS = {"X": "#cc4444", "Y": "#4444cc"}
  53
  54# Shape overlay constants
  55SHAPE_OUTLINE_COLOR = "#4488cc"
  56SHAPE_OUTLINE_WIDTH = 2.5
  57SHAPE_HOVER_FILL = "#4488cc"
  58SHAPE_HOVER_STIPPLE = "gray25"
  59
  60# Drag threshold in pixels — must move this far before drag starts
  61_DRAG_THRESHOLD = 5
  62
  63
  64def _image_search_bases() -> list[Path]:
  65    """Return candidate base directories for image lookup."""
  66    if getattr(sys, 'frozen', False):
  67        return [Path(sys._MEIPASS)]
  68    here = Path(__file__).resolve().parent
  69    return [here, here.parent, here.parent.parent]
  70
  71
  72def _find_image_path() -> Path:
  73    """Locate the Xbox controller image relative to the project root."""
  74    for base in _image_search_bases():
  75        svg_path = base / "images" / "Xbox_Controller.svg"
  76        if svg_path.exists():
  77            return svg_path
  78        png_path = base / "images" / "Xbox_Controller.svg.png"
  79        if png_path.exists():
  80            return png_path
  81    raise FileNotFoundError("Cannot find Xbox_Controller image in images/")
  82
  83
  84def _find_gear_icon() -> Path | None:
  85    """Locate the team gear logo image relative to the project root."""
  86    for base in _image_search_bases():
  87        png_path = base / "images" / "raptacongear.png"
  88        if png_path.exists():
  89            return png_path
  90    return None
  91
  92
  93def _find_rumble_icon() -> Path | None:
  94    """Locate the rumble icon image relative to the project root."""
  95    for base in _image_search_bases():
  96        svg_path = base / "images" / "rumble.svg"
  97        if svg_path.exists():
  98            return svg_path
  99        png_path = base / "images" / "rumble.png"
 100        if png_path.exists():
 101            return png_path
 102    return None
 103
 104
 105class ControllerCanvas(tk.Frame):
 106    """Displays the Xbox controller with interactive binding boxes and
 107    clickable button outlines."""
 108
 109    def __init__(self, parent, on_binding_click=None, on_binding_clear=None,
 110                 on_mouse_coord=None, on_label_moved=None,
 111                 on_hover_input=None, on_hover_shape=None,
 112                 on_action_remove=None,
 113                 label_positions=None,
 114                 icon_loader=None):
 115        """
 116        Args:
 117            parent: tkinter parent widget
 118            on_binding_click: callback(input_name: str) when a binding is clicked
 119            on_binding_clear: callback(input_name: str) to clear an input's bindings
 120            on_mouse_coord: callback(img_x: int, img_y: int) with mouse
 121                position in source-image pixel space (1920x1292)
 122            on_label_moved: callback(input_name: str, img_x: int, img_y: int)
 123                when a binding box is dragged to a new position
 124            on_hover_input: callback(input_name: str | None) when hovering
 125                over a binding box (None when hover leaves)
 126            on_hover_shape: callback(input_names: list[str] | None) when
 127                hovering over a controller shape (None when hover leaves)
 128            on_action_remove: callback(input_name: str, action_name: str) to
 129                remove a single action from an input's bindings
 130            label_positions: dict mapping input_name -> [img_x, img_y] for
 131                custom label positions (loaded from settings)
 132        """
 133        super().__init__(parent)
 134        self._on_binding_click = on_binding_click
 135        self._on_binding_clear = on_binding_clear
 136        self._on_mouse_coord = on_mouse_coord
 137        self._on_label_moved = on_label_moved
 138        self._on_hover_input = on_hover_input
 139        self._on_hover_shape = on_hover_shape
 140        self._on_action_remove = on_action_remove
 141        self._icon_loader = icon_loader
 142        self._label_icon_refs: list[ImageTk.PhotoImage] = []
 143        self._bindings: dict[str, list[str]] = {}
 144
 145        # Custom label positions: input_name -> (img_px_x, img_px_y)
 146        self._custom_label_pos: dict[str, tuple[int, int]] = {}
 147        if label_positions:
 148            for name, pos in label_positions.items():
 149                if isinstance(pos, (list, tuple)) and len(pos) == 2:
 150                    self._custom_label_pos[name] = (int(pos[0]), int(pos[1]))
 151
 152        # DPI-aware font scaling: tkinter font sizes are in points (1/72 inch).
 153        # At 96 DPI (Windows), 9pt = 12 physical pixels.
 154        # At 72 DPI (macOS), 9pt = 9 physical pixels — 25% smaller.
 155        # Correct by scaling fonts up to match the Windows 96 DPI baseline.
 156        try:
 157            actual_dpi = self.winfo_fpixels('1i')
 158            self._dpi_scale = max(96.0 / actual_dpi, 1.0)
 159        except Exception:
 160            self._dpi_scale = 1.0
 161
 162        # Initialize scaled sizes at reference scale (updated each redraw)
 163        self._compute_scaled_sizes(1.0)
 164
 165        # Canvas item tracking
 166        self._box_items: dict[str, list[int]] = {}      # input_name -> item ids
 167        self._line_items: dict[str, int] = {}            # input_name -> line item id
 168        self._shape_items: dict[str, int] = {}           # shape.name -> canvas item id
 169        self._connector_group_items: dict[str, tuple[int, int | None]] = {}
 170        self._shape_map: dict[str, ButtonShape] = {}     # shape.name -> ButtonShape
 171
 172        self._hover_input: str | None = None    # hovered binding box
 173        self._hover_shape: str | None = None    # hovered controller shape
 174        self._selected_input: str | None = None  # selected (red line) input
 175        self._show_borders: bool = False
 176        self._labels_locked: bool = False        # prevent label dragging
 177        self._hide_unassigned: bool = False      # hide inputs with no bindings
 178        self._dragging_from_panel: bool = False  # cross-widget drag active
 179
 180        # Drag state
 181        self._dragging: str | None = None
 182        self._drag_start: tuple[float, float] = (0, 0)
 183        self._did_drag: bool = False
 184
 185        # Drop target highlight (for drag-and-drop from action panel)
 186        self._drop_highlight_id: int | None = None
 187        self._dim_overlay_ids: list[int] = []   # grey overlays on incompatible inputs
 188
 189        # Tooltip
 190        self._tooltip: tk.Toplevel | None = None
 191
 192        # Per-label rumble icon PhotoImages (prevent GC)
 193        self._rumble_label_icons: list[ImageTk.PhotoImage] = []
 194
 195        self._canvas = tk.Canvas(self, bg="white", highlightthickness=0)
 196        self._canvas.pack(fill=tk.BOTH, expand=True)
 197
 198        self._load_image()
 199        self._canvas.bind("<Configure>", self._on_resize)
 200        self._canvas.bind("<Motion>", self._on_mouse_move)
 201        self._canvas.bind("<Button-1>", self._on_press)
 202        self._canvas.bind("<B1-Motion>", self._on_drag)
 203        self._canvas.bind("<ButtonRelease-1>", self._on_release)
 204        self._canvas.bind("<Button-3>", self._on_right_click)
 205        self._canvas.bind("<Leave>", self._on_leave)
 206
 207    def _load_image(self):
 208        """Load the controller image (SVG preferred, PNG fallback)."""
 209        img_path = _find_image_path()
 210
 211        if img_path.suffix == ".svg" and HAS_CAIROSVG:
 212            png_data = cairosvg.svg2png(
 213                url=str(img_path),
 214                output_width=744,
 215                output_height=500,
 216            )
 217            self._base_image = Image.open(io.BytesIO(png_data))
 218        else:
 219            png_path = img_path.with_suffix(".svg.png")
 220            if not png_path.exists():
 221                png_path = img_path
 222            self._base_image = Image.open(str(png_path))
 223
 224        self._img_width = self._base_image.width
 225        self._img_height = self._base_image.height
 226
 227        # Load rumble icon
 228        self._rumble_base_image = None
 229        rumble_path = _find_rumble_icon()
 230        if rumble_path:
 231            try:
 232                if rumble_path.suffix == ".svg" and HAS_CAIROSVG:
 233                    rumble_data = cairosvg.svg2png(
 234                        url=str(rumble_path),
 235                        output_width=64, output_height=64,
 236                    )
 237                    self._rumble_base_image = Image.open(
 238                        io.BytesIO(rumble_data)).convert("RGBA")
 239                elif rumble_path.suffix != ".svg":
 240                    self._rumble_base_image = Image.open(
 241                        str(rumble_path)).convert("RGBA")
 242            except Exception:
 243                pass  # Non-fatal: fallback icon is generated below
 244        # Fallback: draw a simple rumble icon with PIL if SVG couldn't load
 245        if self._rumble_base_image is None:
 246            self._rumble_base_image = self._make_rumble_fallback(64)
 247
 248        # Load team gear logo for top-right overlay
 249        self._gear_base_image = None
 250        gear_path = _find_gear_icon()
 251        if gear_path:
 252            try:
 253                self._gear_base_image = Image.open(
 254                    str(gear_path)).convert("RGBA")
 255            except Exception:
 256                pass  # Non-fatal: gear overlay is optional decoration
 257
 258    @staticmethod
 259    def _make_rumble_fallback(size: int) -> Image.Image:
 260        """Draw a simple rumble/vibration icon when SVG can't be loaded.
 261
 262        Reproduces the key shapes from rumble.svg: a battery-like body
 263        with horizontal bars and a positive terminal nub.
 264        """
 265        img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
 266        d = ImageDraw.Draw(img)
 267        s = size / 24  # scale factor (SVG viewBox is 24x24)
 268
 269        # Main body (rounded-ish rectangle: x=2..15, y=5..16)
 270        d.rectangle([s * 2, s * 5, s * 15, s * 16], fill=(0, 0, 0, 255))
 271        # Terminal nub (x=17..18, y=8..9.5 and x=17..22, y=9.5..11.5)
 272        d.rectangle([s * 17, s * 8, s * 18, s * 11.5], fill=(0, 0, 0, 255))
 273        d.rectangle([s * 17, s * 9.5, s * 22, s * 11.5], fill=(0, 0, 0, 255))
 274        # Inner bars (white gaps to show charge lines)
 275        for y_top in [7, 9, 11, 13]:
 276            d.rectangle([s * 5, s * y_top, s * 14, s * (y_top + 0.8),],
 277                        fill=(255, 255, 255, 255))
 278        # Base plate (x=3..16, y=17..19)
 279        d.rectangle([s * 3, s * 17, s * 16, s * 19], fill=(0, 0, 0, 255))
 280        return img
 281
 282    def set_bindings(self, bindings: dict[str, list[str]]):
 283        """Update the displayed bindings and redraw."""
 284        self._bindings = dict(bindings)
 285        self._redraw()
 286
 287    def set_show_borders(self, show: bool):
 288        """Toggle visibility of shape outlines and redraw."""
 289        self._show_borders = show
 290        self._redraw()
 291
 292    def set_hide_unassigned(self, hide: bool):
 293        """Toggle hiding of inputs with no bindings and redraw."""
 294        self._hide_unassigned = hide
 295        self._redraw()
 296
 297    def set_drag_cursor(self, dragging: bool):
 298        """Set cross-widget drag state so hover cursor is overridden."""
 299        self._dragging_from_panel = dragging
 300        self._canvas.config(cursor="plus" if dragging else "")
 301
 302    def reset_label_positions(self):
 303        """Clear all custom label positions and redraw at defaults."""
 304        self._custom_label_pos.clear()
 305        self._redraw()
 306
 307    def set_labels_locked(self, locked: bool):
 308        """Lock or unlock label dragging."""
 309        self._labels_locked = locked
 310
 311    # --- Drop target highlighting (for drag-and-drop) ---
 312
 313    def _root_to_canvas(self, x_root: int, y_root: int) -> tuple[int, int]:
 314        """Convert root screen coordinates to canvas-local coordinates."""
 315        return x_root - self._canvas.winfo_rootx(), y_root - self._canvas.winfo_rooty()
 316
 317    def highlight_drop_target(self, x_root: int, y_root: int) -> str | None:
 318        """Highlight the binding box or shape under root coords.
 319
 320        Returns the input name if over a single-input target, None otherwise.
 321        """
 322        self.clear_drop_highlight()
 323        cx, cy = self._root_to_canvas(x_root, y_root)
 324
 325        # Check binding boxes first
 326        box_hit = self._hit_test_box(cx, cy)
 327        if box_hit and box_hit in self._box_items:
 328            box_id = self._box_items[box_hit][0]
 329            coords = self._canvas.coords(box_id)
 330            if len(coords) == 4:
 331                self._drop_highlight_id = self._canvas.create_rectangle(
 332                    coords[0] - 2, coords[1] - 2,
 333                    coords[2] + 2, coords[3] + 2,
 334                    outline="#2266cc", width=3, fill="",
 335                )
 336            return box_hit
 337
 338        # Check shapes
 339        shape_hit = self._hit_test_shape(cx, cy)
 340        if shape_hit and shape_hit.name in self._shape_items:
 341            item_id = self._shape_items[shape_hit.name]
 342            coords = self._canvas.coords(item_id)
 343            if len(coords) == 4:
 344                self._drop_highlight_id = self._canvas.create_rectangle(
 345                    coords[0] - 2, coords[1] - 2,
 346                    coords[2] + 2, coords[3] + 2,
 347                    outline="#2266cc", width=3, fill="",
 348                )
 349            if len(shape_hit.inputs) == 1:
 350                return shape_hit.inputs[0]
 351            # Multi-input shape: caller should handle via get_drop_target
 352            return None
 353
 354        return None
 355
 356    def get_drop_target(self, x_root: int, y_root: int):
 357        """Return (input_name, shape) at root coordinates for drop resolution.
 358
 359        Returns:
 360            (str, None) if over a binding box (direct single-input target)
 361            (str, None) if over a single-input shape
 362            (None, ButtonShape) if over a multi-input shape (caller shows menu)
 363            (None, None) if not over anything
 364        """
 365        cx, cy = self._root_to_canvas(x_root, y_root)
 366
 367        box_hit = self._hit_test_box(cx, cy)
 368        if box_hit:
 369            return box_hit, None
 370
 371        shape_hit = self._hit_test_shape(cx, cy)
 372        if shape_hit:
 373            if len(shape_hit.inputs) == 1:
 374                return shape_hit.inputs[0], None
 375            return None, shape_hit
 376
 377        return None, None
 378
 379    def clear_drop_highlight(self):
 380        """Remove any drop target highlighting."""
 381        if self._drop_highlight_id is not None:
 382            self._canvas.delete(self._drop_highlight_id)
 383            self._drop_highlight_id = None
 384
 385    def dim_incompatible_inputs(self, compatible_names: set[str]):
 386        """Grey out incompatible boxes and highlight compatible ones.
 387
 388        Also shows green outlines on controller button shapes that
 389        contain at least one compatible input.
 390        """
 391        self.clear_dim_overlays()
 392        # Highlight/dim binding boxes
 393        for name, item_ids in self._box_items.items():
 394            if not item_ids:
 395                continue
 396            box_id = item_ids[0]
 397            coords = self._canvas.coords(box_id)
 398            if len(coords) != 4:
 399                continue
 400            if name in compatible_names:
 401                # Green highlight border around compatible inputs
 402                oid = self._canvas.create_rectangle(
 403                    coords[0] - 3, coords[1] - 3,
 404                    coords[2] + 3, coords[3] + 3,
 405                    outline="#33aa33", width=3, fill="",
 406                )
 407                self._dim_overlay_ids.append(oid)
 408            else:
 409                # Solid grey overlay on incompatible inputs
 410                oid = self._canvas.create_rectangle(
 411                    coords[0], coords[1], coords[2], coords[3],
 412                    fill="#bbbbbb", outline="#999999", stipple="gray75",
 413                )
 414                self._dim_overlay_ids.append(oid)
 415
 416        # Green outlines on compatible button shapes
 417        for shape_name, shape in self._shape_map.items():
 418            has_compatible = any(
 419                inp in compatible_names for inp in shape.inputs)
 420            if not has_compatible:
 421                continue
 422            item_id = self._shape_items.get(shape_name)
 423            if item_id is None:
 424                continue
 425            coords = self._canvas.coords(item_id)
 426            if len(coords) != 4:
 427                continue
 428            cx = (coords[0] + coords[2]) / 2
 429            cy = (coords[1] + coords[3]) / 2
 430            hw = (coords[2] - coords[0]) / 2
 431            hh = (coords[3] - coords[1]) / 2
 432            if shape.shape in ("circle", "pill"):
 433                oid = self._canvas.create_oval(
 434                    cx - hw, cy - hh, cx + hw, cy + hh,
 435                    outline="#33aa33", width=3, fill="",
 436                )
 437            else:
 438                oid = self._canvas.create_rectangle(
 439                    cx - hw, cy - hh, cx + hw, cy + hh,
 440                    outline="#33aa33", width=3, fill="",
 441                )
 442            self._dim_overlay_ids.append(oid)
 443
 444    def clear_dim_overlays(self):
 445        """Remove drag compatibility overlays."""
 446        for oid in self._dim_overlay_ids:
 447            self._canvas.delete(oid)
 448        self._dim_overlay_ids.clear()
 449
 450    def _on_resize(self, event):
 451        self._redraw()
 452
 453    def _redraw(self):
 454        """Redraw the entire canvas: image, shapes, lines, and binding boxes."""
 455        self._canvas.delete("all")
 456        self._box_items.clear()
 457        self._line_items.clear()
 458        self._shape_items.clear()
 459        self._shape_map.clear()
 460        self._hover_shape = None
 461        self._rumble_label_icons.clear()
 462        self._label_icon_refs.clear()
 463
 464        canvas_w = self._canvas.winfo_width()
 465        canvas_h = self._canvas.winfo_height()
 466        if canvas_w < 10 or canvas_h < 10:
 467            return
 468
 469        # Scale image to fit canvas with proportional padding for labels.
 470        # Reserve a fraction of canvas width for label columns on each side
 471        # so the controller image fills more space as the window shrinks.
 472        pad_frac = 0.12  # fraction of canvas width reserved per side
 473        pad_x = max(50, int(canvas_w * pad_frac))
 474        pad_top = max(20, int(canvas_h * 0.04))
 475        pad_bot = max(50, int(canvas_h * 0.10))  # extra bottom room for Mac DPI scaling
 476        avail_w = canvas_w - 2 * pad_x
 477        avail_h = canvas_h - pad_top - pad_bot
 478
 479        scale = min(avail_w / self._img_width, avail_h / self._img_height)
 480        scale = max(scale, 0.08)
 481        scale = min(scale, 1.5)
 482
 483        new_w = int(self._img_width * scale)
 484        new_h = int(self._img_height * scale)
 485
 486        resized = self._base_image.resize((new_w, new_h), Image.LANCZOS)
 487        self._tk_image = ImageTk.PhotoImage(resized)
 488
 489        # Center horizontally, shift down slightly to leave room for
 490        # rumble icons above the controller and reduce bottom whitespace.
 491        img_x = canvas_w // 2
 492        img_y = int(canvas_h * 0.51)
 493        self._bg_image_id = self._canvas.create_image(
 494            img_x, img_y, image=self._tk_image, anchor=tk.CENTER)
 495
 496        # Store rendered image bounds for fractional coordinate mapping
 497        self._img_left = img_x - new_w // 2
 498        self._img_top = img_y - new_h // 2
 499        self._rendered_w = new_w
 500        self._rendered_h = new_h
 501
 502        # Compute scaled label sizes based on image scale
 503        self._compute_scaled_sizes(scale)
 504
 505        # Draw button outlines on the controller
 506        for shape in XBOX_SHAPES:
 507            self._draw_shape(shape)
 508
 509        # Draw lines and binding boxes for each input
 510        self._group_stack_index: dict[str, int] = {}
 511        self._group_stack_origin: dict[str, tuple[float, float]] = {}
 512        for inp in XBOX_INPUTS:
 513            if self._hide_unassigned and not self._bindings.get(inp.name):
 514                continue
 515            self._draw_input(inp)
 516
 517        # Draw connector bars for grouped labels (D-pad, sticks)
 518        self._draw_connector_groups()
 519
 520        # Draw rumble icons below each rumble label box
 521        self._draw_rumble_icons()
 522
 523        # Draw team logo in top-right corner
 524        self._draw_gear_logo(canvas_w)
 525
 526        # Re-apply selection highlight after redraw
 527        if self._selected_input and self._selected_input in self._line_items:
 528            self._canvas.itemconfig(
 529                self._line_items[self._selected_input],
 530                fill=LINE_SELECTED_COLOR, width=2)
 531
 532    # Label sizes are designed for scale ~0.4-0.5 (typical canvas).
 533    # This multiplier bumps them up so they're readable at normal zoom.
 534    _LABEL_SCALE_BOOST = 1.75
 535
 536    def _compute_scaled_sizes(self, img_scale: float):
 537        """Recompute all label dimensions from the current image scale.
 538
 539        Reference sizes are defined for scale=1.0 (image at native res).
 540        Everything scales linearly so labels shrink/grow with the canvas.
 541        """
 542        s = max(img_scale * self._LABEL_SCALE_BOOST * self._dpi_scale, 0.25)
 543        self._s = s
 544        self._box_w = max(100, int(_REF_BOX_WIDTH * s))
 545        self._box_h = max(22, int(_REF_BOX_HEIGHT * s))
 546        self._box_pad = max(3, int(_REF_BOX_PAD * s))
 547        self._label_font_size = max(8, int(_REF_LABEL_FONT * s))
 548        self._action_font_size = max(9, int(_REF_ACTION_FONT * s))
 549        self._plus_font_size = max(12, int(_REF_PLUS_FONT * s))
 550        self._label_y_offset = max(12, int(_REF_LABEL_Y * s))
 551        self._action_step = max(14, int(_REF_ACTION_STEP * s))
 552        self._icon_size = max(10, self._box_h - int(8 * s))
 553
 554    def _map_frac(self, frac_x: float, frac_y: float) -> tuple[float, float]:
 555        """Map fractional image coordinates (0-1) to canvas pixel position."""
 556        return (
 557            self._img_left + frac_x * self._rendered_w,
 558            self._img_top + frac_y * self._rendered_h,
 559        )
 560
 561    def _unmap_to_img(self, cx: float, cy: float) -> tuple[int, int]:
 562        """Convert canvas pixel position back to source image pixel coords."""
 563        frac_x = (cx - self._img_left) / self._rendered_w if self._rendered_w else 0
 564        frac_y = (cy - self._img_top) / self._rendered_h if self._rendered_h else 0
 565        return int(frac_x * _IMG_W), int(frac_y * _IMG_H)
 566
 567    def _map_label(self, inp: InputCoord, canvas_w: int,
 568                   canvas_h: int) -> tuple[float, float]:
 569        """Map label positions.  Uses custom dragged position if available,
 570        otherwise places at the left or right canvas edge."""
 571        bw = self._box_w
 572        bh = self._box_h
 573        if inp.name in self._custom_label_pos:
 574            img_x, img_y = self._custom_label_pos[inp.name]
 575            lx = self._img_left + (img_x / _IMG_W) * self._rendered_w
 576            ly = self._img_top + (img_y / _IMG_H) * self._rendered_h
 577            lx = max(5, min(lx, canvas_w - bw - 5))
 578            ly = max(5, min(ly, canvas_h - bh - 5))
 579            return lx, ly
 580
 581        lx = self._img_left + inp.label_x * self._rendered_w
 582        ly = self._img_top + inp.label_y * self._rendered_h
 583        lx = max(5, min(lx, canvas_w - bw - 5))
 584        ly = max(5, min(ly, canvas_h - bh - 5))
 585        return lx, ly
 586
 587    # --- Connector bars for grouped inputs ---
 588
 589    # Groups of inputs that share a single leader line + vertical bar.
 590    # Each entry: (prefix to match, anchor input for the leader line)
 591    _CONNECTOR_GROUPS = [
 592        ("pov_", "pov_right"),           # D-pad
 593        ("left_stick", "left_stick_x"),  # Left stick
 594        ("right_stick", "right_stick_x"),  # Right stick
 595    ]
 596
 597    def _get_drag_group(self, name: str) -> list[str]:
 598        """Return all names in the same connector group, or just [name]."""
 599        for prefix, _ in self._CONNECTOR_GROUPS:
 600            if name.startswith(prefix):
 601                return [n for n in self._box_items if n.startswith(prefix)]
 602        return [name]
 603
 604    def _draw_connector_groups(self):
 605        """Draw connector bars for grouped label columns.
 606
 607        Each group gets a vertical bar along the right edge of its label
 608        column, with a single leader line from the shared anchor point
 609        to the bar midpoint.  Items are stored in ``_connector_group_items``
 610        so they can be updated during drag.
 611        """
 612        from .layout_coords import XBOX_INPUT_MAP
 613
 614        self._connector_group_items: dict[str, tuple[int, int]] = {}
 615
 616        for prefix, anchor_name in self._CONNECTOR_GROUPS:
 617            boxes = []
 618            for name, item_ids in self._box_items.items():
 619                if name.startswith(prefix) and item_ids:
 620                    coords = self._canvas.coords(item_ids[0])
 621                    if len(coords) == 4:
 622                        boxes.append(coords)
 623            if not boxes:
 624                continue
 625
 626            right_x = max(c[2] for c in boxes)
 627            top_y = min(c[1] for c in boxes)
 628            bottom_y = max(c[3] for c in boxes)
 629            bar_x = right_x + 6
 630
 631            # Vertical bar
 632            bar_id = self._canvas.create_line(
 633                bar_x, top_y, bar_x, bottom_y,
 634                fill=LINE_COLOR, width=3,
 635            )
 636
 637            # Leader line from anchor to bar midpoint
 638            line_id = None
 639            anchor_inp = XBOX_INPUT_MAP.get(anchor_name)
 640            if anchor_inp:
 641                ax, ay = self._map_frac(anchor_inp.anchor_x,
 642                                        anchor_inp.anchor_y)
 643                bar_mid_y = (top_y + bottom_y) / 2
 644                line_id = self._canvas.create_line(
 645                    ax, ay, bar_x, bar_mid_y,
 646                    fill=LINE_COLOR, width=1,
 647                )
 648
 649            self._connector_group_items[prefix] = (bar_id, line_id)
 650
 651    def _update_connector_group(self, prefix: str):
 652        """Reposition the connector bar and leader line for a group."""
 653        from .layout_coords import XBOX_INPUT_MAP
 654
 655        items = self._connector_group_items.get(prefix)
 656        if not items:
 657            return
 658        bar_id, line_id = items
 659
 660        boxes = []
 661        for name, item_ids in self._box_items.items():
 662            if name.startswith(prefix) and item_ids:
 663                coords = self._canvas.coords(item_ids[0])
 664                if len(coords) == 4:
 665                    boxes.append(coords)
 666        if not boxes:
 667            return
 668
 669        right_x = max(c[2] for c in boxes)
 670        top_y = min(c[1] for c in boxes)
 671        bottom_y = max(c[3] for c in boxes)
 672        bar_x = right_x + 6
 673
 674        self._canvas.coords(bar_id, bar_x, top_y, bar_x, bottom_y)
 675
 676        if line_id:
 677            anchor_name = None
 678            for p, a in self._CONNECTOR_GROUPS:
 679                if p == prefix:
 680                    anchor_name = a
 681                    break
 682            anchor_inp = XBOX_INPUT_MAP.get(anchor_name) if anchor_name else None
 683            if anchor_inp:
 684                ax, ay = self._map_frac(anchor_inp.anchor_x,
 685                                        anchor_inp.anchor_y)
 686                bar_mid_y = (top_y + bottom_y) / 2
 687                self._canvas.coords(line_id, ax, ay, bar_x, bar_mid_y)
 688
 689    # --- Rumble icons ---
 690
 691    def _draw_rumble_icons(self):
 692        """Draw a rumble icon on the controller at each rumble anchor."""
 693        from .layout_coords import XBOX_INPUT_MAP
 694        if not self._rumble_base_image:
 695            return
 696        icon_size = self._box_w // 4
 697        resized = self._rumble_base_image.resize(
 698            (icon_size, icon_size), Image.LANCZOS)
 699
 700        for name in ["rumble_left", "rumble_both", "rumble_right"]:
 701            inp = XBOX_INPUT_MAP.get(name)
 702            if not inp:
 703                continue
 704            cx, cy = self._map_frac(inp.anchor_x, inp.anchor_y)
 705            tk_icon = ImageTk.PhotoImage(resized)
 706            self._rumble_label_icons.append(tk_icon)
 707            self._canvas.create_image(
 708                cx, cy, image=tk_icon, anchor=tk.CENTER)
 709
 710    # --- Gear logo ---
 711
 712    def _draw_gear_logo(self, canvas_w: int):
 713        """Draw the team gear logo in the top-right corner of the canvas."""
 714        if not self._gear_base_image:
 715            return
 716        # ~96px (approx 1 inch at standard DPI)
 717        logo_size = 96
 718        resized = self._gear_base_image.resize(
 719            (logo_size, logo_size), Image.LANCZOS)
 720        self._gear_tk_image = ImageTk.PhotoImage(resized)
 721        margin = 8
 722        self._canvas.create_image(
 723            canvas_w - margin, margin,
 724            image=self._gear_tk_image, anchor=tk.NE)
 725
 726    # --- Shape drawing ---
 727
 728    def _draw_shape(self, shape: ButtonShape):
 729        """Draw a bold outline on the controller for a button/stick/trigger."""
 730        cx, cy = self._map_frac(shape.center_x, shape.center_y)
 731        hw = shape.width * self._rendered_w / 2
 732        hh = shape.height * self._rendered_h / 2
 733
 734        outline = SHAPE_OUTLINE_COLOR if self._show_borders else ""
 735        width = SHAPE_OUTLINE_WIDTH if self._show_borders else 0
 736
 737        if shape.shape == "circle":
 738            item = self._canvas.create_oval(
 739                cx - hw, cy - hh, cx + hw, cy + hh,
 740                outline=outline, width=width,
 741                fill="",
 742            )
 743        elif shape.shape == "pill":
 744            # Rounded rectangle approximation using an oval
 745            item = self._canvas.create_oval(
 746                cx - hw, cy - hh, cx + hw, cy + hh,
 747                outline=outline, width=width,
 748                fill="",
 749            )
 750        else:  # rect
 751            item = self._canvas.create_rectangle(
 752                cx - hw, cy - hh, cx + hw, cy + hh,
 753                outline=outline, width=width,
 754                fill="",
 755            )
 756
 757        self._shape_items[shape.name] = item
 758        self._shape_map[shape.name] = shape
 759
 760    # --- Binding box drawing ---
 761
 762    # D-pad inputs are stacked in canvas-pixel space so spacing is
 763    # consistent regardless of zoom level.
 764    _STACK_GROUPS = {"pov_"}
 765
 766    def _draw_input(self, inp: InputCoord):
 767        """Draw a single input's leader line and binding box."""
 768        canvas_w = self._canvas.winfo_width()
 769        canvas_h = self._canvas.winfo_height()
 770
 771        # Scaled sizes
 772        bw = self._box_w
 773        bh = self._box_h
 774        bp = self._box_pad
 775        lf = self._label_font_size
 776        af = self._action_font_size
 777        pf = self._plus_font_size
 778        ly_off = self._label_y_offset
 779        a_step = self._action_step
 780        ic_sz = self._icon_size
 781
 782        ax, ay = self._map_frac(inp.anchor_x, inp.anchor_y)
 783        lx, ly = self._map_label(inp, canvas_w, canvas_h)
 784
 785        # Stack grouped labels at fixed canvas-pixel intervals
 786        for prefix in self._STACK_GROUPS:
 787            if inp.name.startswith(prefix):
 788                if prefix not in self._group_stack_origin:
 789                    self._group_stack_origin[prefix] = (lx, ly)
 790                    self._group_stack_index[prefix] = 0
 791                else:
 792                    idx = self._group_stack_index[prefix] + 1
 793                    self._group_stack_index[prefix] = idx
 794                    origin_x, origin_y = self._group_stack_origin[prefix]
 795                    lx = origin_x
 796                    ly = origin_y + idx * bh
 797                break
 798
 799        box_cx = lx + bw / 2
 800        box_cy = ly + bh / 2
 801
 802        # Leader line — skip for grouped inputs (connector bar drawn separately)
 803        is_grouped = (inp.name.startswith("pov_")
 804                      or inp.name.startswith("left_stick")
 805                      or inp.name.startswith("right_stick"))
 806        if not is_grouped:
 807            line_id = self._canvas.create_line(
 808                ax, ay, box_cx, box_cy,
 809                fill=LINE_COLOR, width=1,
 810            )
 811            self._line_items[inp.name] = line_id
 812
 813        # Assigned actions — D-pad single-line, sticks max 2
 814        actions = self._bindings.get(inp.name, [])
 815        is_dpad = inp.name.startswith("pov_")
 816        is_stick = (inp.name.startswith("left_stick")
 817                    or inp.name.startswith("right_stick"))
 818        all_actions = actions
 819        if is_dpad:
 820            actions = all_actions[:1]
 821        elif is_stick:
 822            actions = all_actions[:2]
 823        has_actions = len(all_actions) > 0
 824        fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
 825        if is_dpad:
 826            total_height = bh
 827        elif has_actions:
 828            total_height = ly_off + len(actions) * a_step
 829        else:
 830            total_height = bh
 831
 832        # Box background
 833        box_id = self._canvas.create_rectangle(
 834            lx, ly, lx + bw, ly + total_height,
 835            fill=fill, outline=BOX_OUTLINE, width=1,
 836        )
 837
 838        axis_tag = None
 839        if inp.name.endswith("_x"):
 840            axis_tag = "X"
 841        elif inp.name.endswith("_y"):
 842            axis_tag = "Y"
 843
 844        items = [box_id]
 845
 846        # Input icon (scaled)
 847        text_offset = bp
 848        if self._icon_loader:
 849            icon = self._icon_loader.get_tk_icon(inp.name, ic_sz)
 850            if icon:
 851                self._label_icon_refs.append(icon)
 852                icon_id = self._canvas.create_image(
 853                    lx + bp, ly + 1, image=icon, anchor=tk.NW)
 854                items.append(icon_id)
 855                text_offset = bp + ic_sz + max(2, int(4 * self._s))
 856
 857        # Input label
 858        label_color = (AXIS_INDICATOR_COLORS[axis_tag]
 859                       if axis_tag else "#555555")
 860
 861        if is_dpad:
 862            # D-pad compact: icon + label + action on one line
 863            line_text = inp.display_name
 864            if has_actions:
 865                line_text += " : " + actions[0]
 866            label_id = self._canvas.create_text(
 867                lx + text_offset, ly + 2,
 868                text=line_text, anchor=tk.NW,
 869                font=("Arial", lf, "bold" if has_actions else ""),
 870                fill=ASSIGNED_COLOR if has_actions else label_color,
 871            )
 872            items.append(label_id)
 873            # "+" indicator when extra bindings are hidden
 874            if has_actions and len(all_actions) > 1:
 875                plus_id = self._canvas.create_text(
 876                    lx + bw + max(2, int(4 * self._s)), ly - int(4 * self._s),
 877                    text="+", anchor=tk.NW,
 878                    font=("Arial", pf, "bold"), fill=ASSIGNED_COLOR,
 879                )
 880                items.append(plus_id)
 881        else:
 882            label_id = self._canvas.create_text(
 883                lx + text_offset, ly + 2,
 884                text=inp.display_name, anchor=tk.NW,
 885                font=("Arial", lf), fill=label_color,
 886            )
 887            items.append(label_id)
 888
 889            # Action names or unassigned text
 890            if has_actions:
 891                for i, action in enumerate(actions):
 892                    txt_id = self._canvas.create_text(
 893                        lx + bp, ly + ly_off + i * a_step,
 894                        text=action, anchor=tk.NW,
 895                        font=("Arial", af, "bold"), fill=ASSIGNED_COLOR,
 896                    )
 897                    items.append(txt_id)
 898                # "+" when actions are truncated (sticks capped at 2)
 899                if len(all_actions) > len(actions):
 900                    plus_id = self._canvas.create_text(
 901                        lx + bw + max(2, int(4 * self._s)),
 902                        ly - int(4 * self._s),
 903                        text="+", anchor=tk.NW,
 904                        font=("Arial", pf, "bold"), fill=ASSIGNED_COLOR,
 905                    )
 906                    items.append(plus_id)
 907            else:
 908                txt_id = self._canvas.create_text(
 909                    lx + bp, ly + ly_off,
 910                    text=UNASSIGNED_TEXT, anchor=tk.NW,
 911                    font=("Arial", af), fill=UNASSIGNED_COLOR,
 912                )
 913                items.append(txt_id)
 914
 915        self._box_items[inp.name] = items
 916
 917    # --- Hit testing ---
 918
 919    def _hit_test_box(self, x: float, y: float) -> str | None:
 920        """Return input name if (x, y) is inside a binding box."""
 921        for name, item_ids in self._box_items.items():
 922            if not item_ids:
 923                continue
 924            box_id = item_ids[0]
 925            coords = self._canvas.coords(box_id)
 926            if len(coords) == 4:
 927                x1, y1, x2, y2 = coords
 928                if x1 <= x <= x2 and y1 <= y <= y2:
 929                    return name
 930        return None
 931
 932    def _hit_test_shape(self, x: float, y: float) -> ButtonShape | None:
 933        """Return the ButtonShape if (x, y) is inside a controller outline."""
 934        for shape_name, item_id in self._shape_items.items():
 935            coords = self._canvas.coords(item_id)
 936            if len(coords) == 4:
 937                x1, y1, x2, y2 = coords
 938                shape = self._shape_map[shape_name]
 939                if shape.shape == "circle":
 940                    # Ellipse hit test
 941                    cx = (x1 + x2) / 2
 942                    cy = (y1 + y2) / 2
 943                    rx = (x2 - x1) / 2
 944                    ry = (y2 - y1) / 2
 945                    if rx > 0 and ry > 0:
 946                        if ((x - cx) ** 2 / rx ** 2
 947                                + (y - cy) ** 2 / ry ** 2) <= 1:
 948                            return shape
 949                else:
 950                    if x1 <= x <= x2 and y1 <= y <= y2:
 951                        return shape
 952        return None
 953
 954    # --- Selection ---
 955
 956    def clear_selection(self):
 957        """Clear the selected input, restoring line to default color."""
 958        self._select_input(None)
 959
 960    def _select_input(self, name: str | None):
 961        """Set the selected input, updating line colors."""
 962        # Deselect previous
 963        if (self._selected_input
 964                and self._selected_input in self._line_items):
 965            self._canvas.itemconfig(
 966                self._line_items[self._selected_input],
 967                fill=LINE_COLOR, width=1)
 968        # Select new
 969        self._selected_input = name
 970        if name and name in self._line_items:
 971            line_id = self._line_items[name]
 972            self._canvas.itemconfig(
 973                line_id, fill=LINE_SELECTED_COLOR, width=2)
 974            # Ensure line is visible above the background image
 975            if hasattr(self, '_bg_image_id'):
 976                self._canvas.tag_raise(line_id, self._bg_image_id)
 977
 978    # --- Drag helpers ---
 979
 980    def _move_box(self, name: str, dx: float, dy: float):
 981        """Move all canvas items for a binding box by (dx, dy)."""
 982        for item_id in self._box_items.get(name, []):
 983            self._canvas.move(item_id, dx, dy)
 984
 985    def _update_line_for_box(self, name: str):
 986        """Recreate the leader line to the current box center position."""
 987        if name not in self._line_items or name not in self._box_items:
 988            return
 989        from .layout_coords import XBOX_INPUT_MAP
 990        inp = XBOX_INPUT_MAP.get(name)
 991        if not inp:
 992            return
 993
 994        # Anchor point on the controller image
 995        ax, ay = self._map_frac(inp.anchor_x, inp.anchor_y)
 996
 997        # Current box center
 998        box_coords = self._canvas.coords(self._box_items[name][0])
 999        if len(box_coords) != 4:
1000            return
1001        box_cx = (box_coords[0] + box_coords[2]) / 2
1002        box_cy = (box_coords[1] + box_coords[3]) / 2
1003
1004        # Delete old line and create new
1005        old_line = self._line_items[name]
1006        self._canvas.delete(old_line)
1007
1008        is_selected = (name == self._selected_input)
1009        color = LINE_SELECTED_COLOR if is_selected else LINE_COLOR
1010        width = 2 if is_selected else 1
1011
1012        new_line = self._canvas.create_line(
1013            ax, ay, box_cx, box_cy,
1014            fill=color, width=width,
1015        )
1016        self._line_items[name] = new_line
1017        # Place line above the background image but below boxes/shapes
1018        self._canvas.tag_raise(new_line, self._bg_image_id)
1019
1020    # --- Tooltip ---
1021
1022    @staticmethod
1023    def _input_description(inp: InputCoord) -> str:
1024        """Build a one-line type description for an input."""
1025        if inp.input_type == "axis":
1026            if inp.name.endswith("_x"):
1027                return "X Axis float [-1 (Left), 1 (Right)]"
1028            elif inp.name.endswith("_y"):
1029                return "Y Axis float [-1 (Up), 1 (Down)]"
1030            else:
1031                return "Axis float [0 (Released), 1 (Pressed)]"
1032        elif inp.name.startswith("pov_"):
1033            pov_degrees = {
1034                "pov_up": 0, "pov_up_right": 45,
1035                "pov_right": 90, "pov_down_right": 135,
1036                "pov_down": 180, "pov_down_left": 225,
1037                "pov_left": 270, "pov_up_left": 315,
1038            }
1039            deg = pov_degrees.get(inp.name, "?")
1040            return f"D-Pad [{deg}\u00b0, Button]"
1041        elif inp.input_type == "output":
1042            return "Output float [0.0 (Off), 1.0 (Max)]"
1043        else:
1044            return "Button [Boolean]"
1045
1046    def _build_tooltip_text(self, shape: ButtonShape) -> str:
1047        """Build multi-line tooltip text for a controller shape."""
1048        from .layout_coords import XBOX_INPUT_MAP
1049
1050        # Title from the shape's first input's common name
1051        title_map = {
1052            "ls": "Left Analog Stick", "rs": "Right Analog Stick",
1053            "lt": "Left Trigger", "rt": "Right Trigger",
1054            "lb": "Left Bumper", "rb": "Right Bumper",
1055            "a": "A Button", "b": "B Button",
1056            "x": "X Button", "y": "Y Button",
1057            "back": "Back Button", "start": "Start Button",
1058            "dpad": "D-Pad",
1059            "rumble_l": "Left Rumble", "rumble_b": "Both Rumble",
1060            "rumble_r": "Right Rumble",
1061        }
1062        title = title_map.get(shape.name, shape.name)
1063        lines = [title]
1064        for input_name in shape.inputs:
1065            inp = XBOX_INPUT_MAP.get(input_name)
1066            if inp:
1067                lines.append(f"  {inp.display_name}: {self._input_description(inp)}")
1068        return "\n".join(lines)
1069
1070    def _show_tooltip(self, x_root: int, y_root: int, text: str):
1071        """Show or update the tooltip near the cursor."""
1072        if self._tooltip:
1073            label = self._tooltip.winfo_children()[0]
1074            label.config(text=text)
1075            self._tooltip.geometry(f"+{x_root + 15}+{y_root + 10}")
1076            self._tooltip.deiconify()
1077            return
1078
1079        self._tooltip = tw = tk.Toplevel(self)
1080        tw.wm_overrideredirect(True)
1081        tw.wm_geometry(f"+{x_root + 15}+{y_root + 10}")
1082        tw.attributes("-topmost", True)
1083        label = tk.Label(
1084            tw, text=text, justify=tk.LEFT,
1085            background="#ffffe0", foreground="#222222",
1086            relief=tk.SOLID, borderwidth=1,
1087            font=("Arial", 9), padx=6, pady=4,
1088        )
1089        label.pack()
1090
1091    def _hide_tooltip(self):
1092        """Hide the tooltip."""
1093        if self._tooltip:
1094            self._tooltip.withdraw()
1095
1096    # --- Event handlers ---
1097
1098    def _on_mouse_move(self, event):
1099        """Highlight binding boxes and controller shapes on hover."""
1100        # Skip hover highlighting while dragging
1101        if self._dragging:
1102            return
1103
1104        # Check binding boxes first (they're on top)
1105        box_hit = self._hit_test_box(event.x, event.y)
1106        shape_hit = self._hit_test_shape(event.x, event.y)
1107
1108        # Update binding box highlight
1109        if box_hit != self._hover_input:
1110            # Unhighlight previous hover box
1111            if self._hover_input and self._hover_input in self._box_items:
1112                items = self._box_items[self._hover_input]
1113                if items:
1114                    has_actions = bool(
1115                        self._bindings.get(self._hover_input))
1116                    fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
1117                    self._canvas.itemconfig(items[0], fill=fill)
1118                # Restore line color unless this input is selected
1119                if (self._hover_input != self._selected_input
1120                        and self._hover_input in self._line_items):
1121                    self._canvas.itemconfig(
1122                        self._line_items[self._hover_input],
1123                        fill=LINE_COLOR, width=1)
1124
1125            # Highlight new hover box
1126            if box_hit and box_hit in self._box_items:
1127                items = self._box_items[box_hit]
1128                if items:
1129                    self._canvas.itemconfig(items[0], fill=BOX_FILL_HOVER)
1130                # Turn line red on hover
1131                if box_hit in self._line_items:
1132                    self._canvas.itemconfig(
1133                        self._line_items[box_hit],
1134                        fill=LINE_SELECTED_COLOR, width=2)
1135
1136            self._hover_input = box_hit
1137
1138            # Notify parent of hovered input change
1139            if self._on_hover_input:
1140                self._on_hover_input(box_hit)
1141
1142        # Update shape highlight
1143        new_shape_name = shape_hit.name if shape_hit else None
1144        if new_shape_name != self._hover_shape:
1145            # Unhighlight previous shape
1146            if self._hover_shape and self._hover_shape in self._shape_items:
1147                item_id = self._shape_items[self._hover_shape]
1148                rest_outline = SHAPE_OUTLINE_COLOR if self._show_borders else ""
1149                rest_width = SHAPE_OUTLINE_WIDTH if self._show_borders else 0
1150                self._canvas.itemconfig(
1151                    item_id, outline=rest_outline, width=rest_width)
1152
1153            # Highlight new shape (always show on hover)
1154            if new_shape_name and new_shape_name in self._shape_items:
1155                item_id = self._shape_items[new_shape_name]
1156                self._canvas.itemconfig(
1157                    item_id, outline="#2266aa", width=SHAPE_OUTLINE_WIDTH + 1.5)
1158
1159            self._hover_shape = new_shape_name
1160
1161            # Notify parent of hovered shape change
1162            if self._on_hover_shape:
1163                if shape_hit:
1164                    self._on_hover_shape(list(shape_hit.inputs))
1165                else:
1166                    self._on_hover_shape(None)
1167
1168        # Tooltip for shapes
1169        if shape_hit and not box_hit:
1170            text = self._build_tooltip_text(shape_hit)
1171            self._show_tooltip(event.x_root, event.y_root, text)
1172        else:
1173            self._hide_tooltip()
1174
1175        # Cursor (don't override plus cursor during cross-widget drag)
1176        if not self._dragging_from_panel:
1177            if box_hit or shape_hit:
1178                self._canvas.config(cursor="hand2")
1179            else:
1180                self._canvas.config(cursor="")
1181
1182        # Report image-space coordinates to parent
1183        if self._on_mouse_coord and hasattr(self, '_rendered_w'):
1184            img_x, img_y = self._unmap_to_img(event.x, event.y)
1185            self._on_mouse_coord(img_x, img_y)
1186
1187    def _on_leave(self, event):
1188        """Hide tooltip when the cursor leaves the canvas."""
1189        self._hide_tooltip()
1190
1191    def _on_press(self, event):
1192        """Handle mouse button press — start potential drag or shape click."""
1193        box_hit = self._hit_test_box(event.x, event.y)
1194        if box_hit:
1195            self._dragging = box_hit
1196            self._drag_start = (event.x, event.y)
1197            self._did_drag = False
1198            self._select_input(box_hit)
1199            return
1200
1201        # If not on a box, clear drag state
1202        self._dragging = None
1203        self._did_drag = False
1204
1205    def _on_drag(self, event):
1206        """Handle mouse drag — move binding box if dragging."""
1207        if not self._dragging or self._labels_locked:
1208            return
1209
1210        dx = event.x - self._drag_start[0]
1211        dy = event.y - self._drag_start[1]
1212
1213        if not self._did_drag:
1214            dist = math.hypot(dx, dy)
1215            if dist < _DRAG_THRESHOLD:
1216                return
1217            self._did_drag = True
1218            self._canvas.config(cursor="fleur")
1219
1220        # Move the box items — group drag for connected labels
1221        group_names = self._get_drag_group(self._dragging)
1222        for gname in group_names:
1223            self._move_box(gname, dx, dy)
1224            self._update_line_for_box(gname)
1225
1226        # Update connector bar/line for the dragged group
1227        for prefix, _ in self._CONNECTOR_GROUPS:
1228            if self._dragging.startswith(prefix):
1229                self._update_connector_group(prefix)
1230                break
1231
1232        self._drag_start = (event.x, event.y)
1233
1234    def _on_release(self, event):
1235        """Handle mouse button release — finish drag or fire click."""
1236        name = self._dragging
1237        self._dragging = None
1238
1239        if name and self._did_drag:
1240            # Drag finished — save positions for all group members
1241            for gname in self._get_drag_group(name):
1242                box_items = self._box_items.get(gname, [])
1243                if box_items:
1244                    coords = self._canvas.coords(box_items[0])
1245                    if len(coords) == 4:
1246                        lx, ly = coords[0], coords[1]
1247                        img_x, img_y = self._unmap_to_img(lx, ly)
1248                        self._custom_label_pos[gname] = (img_x, img_y)
1249                        if self._on_label_moved:
1250                            self._on_label_moved(gname, img_x, img_y)
1251            self._canvas.config(cursor="hand2")
1252            self._did_drag = False
1253            return
1254
1255        self._did_drag = False
1256
1257        # Was a click (no drag) — handle binding click or shape click
1258        if name:
1259            # Clicked on a binding box
1260            if self._on_binding_click:
1261                self._on_binding_click(name)
1262            return
1263
1264        # Check shapes (click wasn't on a box)
1265        shape_hit = self._hit_test_shape(event.x, event.y)
1266        if shape_hit and self._on_binding_click:
1267            if len(shape_hit.inputs) == 1:
1268                self._select_input(shape_hit.inputs[0])
1269                self._on_binding_click(shape_hit.inputs[0])
1270            else:
1271                self._show_input_menu(event, shape_hit)
1272
1273    def _on_right_click(self, event):
1274        """Show a context menu for clearing actions on right-click.
1275
1276        Works on binding boxes (labels) and controller shapes (buttons).
1277        Shows individual action removal items plus Clear All.
1278        """
1279        box_hit = self._hit_test_box(event.x, event.y)
1280        if box_hit:
1281            self._show_binding_context_menu(event, box_hit)
1282            return
1283
1284        shape_hit = self._hit_test_shape(event.x, event.y)
1285        if shape_hit:
1286            self._show_shape_context_menu(event, shape_hit)
1287
1288    def _show_binding_context_menu(self, event, input_name: str):
1289        """Context menu for a binding box: remove individual actions + clear all."""
1290        self._select_input(input_name)
1291        actions = self._bindings.get(input_name, [])
1292        menu = tk.Menu(self._canvas, tearoff=0)
1293
1294        if actions:
1295            for action in actions:
1296                menu.add_command(
1297                    label=f"Remove: {action}",
1298                    command=lambda n=input_name, a=action:
1299                        self._on_action_remove(n, a)
1300                        if self._on_action_remove else None,
1301                )
1302            menu.add_separator()
1303            menu.add_command(
1304                label="Clear All",
1305                command=lambda n=input_name: self._on_binding_clear(n)
1306                        if self._on_binding_clear else None,
1307            )
1308        else:
1309            menu.add_command(label="(no actions bound)", state=tk.DISABLED)
1310
1311        menu.tk_popup(event.x_root, event.y_root)
1312
1313    def _show_shape_context_menu(self, event, shape: ButtonShape):
1314        """Context menu for a controller shape: remove individual actions + clear all."""
1315        from .layout_coords import XBOX_INPUT_MAP
1316
1317        menu = tk.Menu(self._canvas, tearoff=0)
1318        has_any = False
1319
1320        for input_name in shape.inputs:
1321            actions = self._bindings.get(input_name, [])
1322            if not actions:
1323                continue
1324            has_any = True
1325            inp = XBOX_INPUT_MAP.get(input_name)
1326            display = inp.display_name if inp else input_name
1327            for action in actions:
1328                menu.add_command(
1329                    label=f"Remove: {action} ({display})",
1330                    command=lambda n=input_name, a=action:
1331                        self._on_action_remove(n, a)
1332                        if self._on_action_remove else None,
1333                )
1334
1335        if has_any:
1336            menu.add_separator()
1337            menu.add_command(
1338                label="Clear All",
1339                command=lambda: self._clear_shape_bindings(shape),
1340            )
1341        else:
1342            menu.add_command(label="(no actions bound)", state=tk.DISABLED)
1343
1344        menu.tk_popup(event.x_root, event.y_root)
1345
1346    def _clear_shape_bindings(self, shape: ButtonShape):
1347        """Clear bindings for all inputs of a shape."""
1348        if not self._on_binding_clear:
1349            return
1350        for input_name in shape.inputs:
1351            if self._bindings.get(input_name):
1352                self._on_binding_clear(input_name)
1353
1354    def _show_input_menu(self, event, shape: ButtonShape):
1355        """Show a context menu to pick which input to configure
1356        when a shape maps to multiple inputs (e.g., stick X/Y/button)."""
1357        from .layout_coords import XBOX_INPUT_MAP
1358
1359        menu = tk.Menu(self._canvas, tearoff=0)
1360        for input_name in shape.inputs:
1361            inp = XBOX_INPUT_MAP.get(input_name)
1362            display = inp.display_name if inp else input_name
1363            # Capture input_name in the lambda default arg
1364            menu.add_command(
1365                label=display,
1366                command=lambda n=input_name: self._on_binding_click(n),
1367            )
1368        menu.tk_popup(event.x_root, event.y_root)
BOX_WIDTH = 220
BOX_HEIGHT = 40
BOX_PAD = 6
LINE_COLOR = '#555555'
LINE_SELECTED_COLOR = '#cc0000'
BOX_OUTLINE = '#888888'
BOX_FILL = '#f0f0f0'
BOX_FILL_HOVER = '#ddeeff'
BOX_FILL_ASSIGNED = '#d4edda'
UNASSIGNED_TEXT = '(unassigned)'
UNASSIGNED_COLOR = '#999999'
ASSIGNED_COLOR = '#222222'
AXIS_INDICATOR_COLORS = {'X': '#cc4444', 'Y': '#4444cc'}
SHAPE_OUTLINE_COLOR = '#4488cc'
SHAPE_OUTLINE_WIDTH = 2.5
SHAPE_HOVER_FILL = '#4488cc'
SHAPE_HOVER_STIPPLE = 'gray25'
class ControllerCanvas(tkinter.Frame):
 106class ControllerCanvas(tk.Frame):
 107    """Displays the Xbox controller with interactive binding boxes and
 108    clickable button outlines."""
 109
 110    def __init__(self, parent, on_binding_click=None, on_binding_clear=None,
 111                 on_mouse_coord=None, on_label_moved=None,
 112                 on_hover_input=None, on_hover_shape=None,
 113                 on_action_remove=None,
 114                 label_positions=None,
 115                 icon_loader=None):
 116        """
 117        Args:
 118            parent: tkinter parent widget
 119            on_binding_click: callback(input_name: str) when a binding is clicked
 120            on_binding_clear: callback(input_name: str) to clear an input's bindings
 121            on_mouse_coord: callback(img_x: int, img_y: int) with mouse
 122                position in source-image pixel space (1920x1292)
 123            on_label_moved: callback(input_name: str, img_x: int, img_y: int)
 124                when a binding box is dragged to a new position
 125            on_hover_input: callback(input_name: str | None) when hovering
 126                over a binding box (None when hover leaves)
 127            on_hover_shape: callback(input_names: list[str] | None) when
 128                hovering over a controller shape (None when hover leaves)
 129            on_action_remove: callback(input_name: str, action_name: str) to
 130                remove a single action from an input's bindings
 131            label_positions: dict mapping input_name -> [img_x, img_y] for
 132                custom label positions (loaded from settings)
 133        """
 134        super().__init__(parent)
 135        self._on_binding_click = on_binding_click
 136        self._on_binding_clear = on_binding_clear
 137        self._on_mouse_coord = on_mouse_coord
 138        self._on_label_moved = on_label_moved
 139        self._on_hover_input = on_hover_input
 140        self._on_hover_shape = on_hover_shape
 141        self._on_action_remove = on_action_remove
 142        self._icon_loader = icon_loader
 143        self._label_icon_refs: list[ImageTk.PhotoImage] = []
 144        self._bindings: dict[str, list[str]] = {}
 145
 146        # Custom label positions: input_name -> (img_px_x, img_px_y)
 147        self._custom_label_pos: dict[str, tuple[int, int]] = {}
 148        if label_positions:
 149            for name, pos in label_positions.items():
 150                if isinstance(pos, (list, tuple)) and len(pos) == 2:
 151                    self._custom_label_pos[name] = (int(pos[0]), int(pos[1]))
 152
 153        # DPI-aware font scaling: tkinter font sizes are in points (1/72 inch).
 154        # At 96 DPI (Windows), 9pt = 12 physical pixels.
 155        # At 72 DPI (macOS), 9pt = 9 physical pixels — 25% smaller.
 156        # Correct by scaling fonts up to match the Windows 96 DPI baseline.
 157        try:
 158            actual_dpi = self.winfo_fpixels('1i')
 159            self._dpi_scale = max(96.0 / actual_dpi, 1.0)
 160        except Exception:
 161            self._dpi_scale = 1.0
 162
 163        # Initialize scaled sizes at reference scale (updated each redraw)
 164        self._compute_scaled_sizes(1.0)
 165
 166        # Canvas item tracking
 167        self._box_items: dict[str, list[int]] = {}      # input_name -> item ids
 168        self._line_items: dict[str, int] = {}            # input_name -> line item id
 169        self._shape_items: dict[str, int] = {}           # shape.name -> canvas item id
 170        self._connector_group_items: dict[str, tuple[int, int | None]] = {}
 171        self._shape_map: dict[str, ButtonShape] = {}     # shape.name -> ButtonShape
 172
 173        self._hover_input: str | None = None    # hovered binding box
 174        self._hover_shape: str | None = None    # hovered controller shape
 175        self._selected_input: str | None = None  # selected (red line) input
 176        self._show_borders: bool = False
 177        self._labels_locked: bool = False        # prevent label dragging
 178        self._hide_unassigned: bool = False      # hide inputs with no bindings
 179        self._dragging_from_panel: bool = False  # cross-widget drag active
 180
 181        # Drag state
 182        self._dragging: str | None = None
 183        self._drag_start: tuple[float, float] = (0, 0)
 184        self._did_drag: bool = False
 185
 186        # Drop target highlight (for drag-and-drop from action panel)
 187        self._drop_highlight_id: int | None = None
 188        self._dim_overlay_ids: list[int] = []   # grey overlays on incompatible inputs
 189
 190        # Tooltip
 191        self._tooltip: tk.Toplevel | None = None
 192
 193        # Per-label rumble icon PhotoImages (prevent GC)
 194        self._rumble_label_icons: list[ImageTk.PhotoImage] = []
 195
 196        self._canvas = tk.Canvas(self, bg="white", highlightthickness=0)
 197        self._canvas.pack(fill=tk.BOTH, expand=True)
 198
 199        self._load_image()
 200        self._canvas.bind("<Configure>", self._on_resize)
 201        self._canvas.bind("<Motion>", self._on_mouse_move)
 202        self._canvas.bind("<Button-1>", self._on_press)
 203        self._canvas.bind("<B1-Motion>", self._on_drag)
 204        self._canvas.bind("<ButtonRelease-1>", self._on_release)
 205        self._canvas.bind("<Button-3>", self._on_right_click)
 206        self._canvas.bind("<Leave>", self._on_leave)
 207
 208    def _load_image(self):
 209        """Load the controller image (SVG preferred, PNG fallback)."""
 210        img_path = _find_image_path()
 211
 212        if img_path.suffix == ".svg" and HAS_CAIROSVG:
 213            png_data = cairosvg.svg2png(
 214                url=str(img_path),
 215                output_width=744,
 216                output_height=500,
 217            )
 218            self._base_image = Image.open(io.BytesIO(png_data))
 219        else:
 220            png_path = img_path.with_suffix(".svg.png")
 221            if not png_path.exists():
 222                png_path = img_path
 223            self._base_image = Image.open(str(png_path))
 224
 225        self._img_width = self._base_image.width
 226        self._img_height = self._base_image.height
 227
 228        # Load rumble icon
 229        self._rumble_base_image = None
 230        rumble_path = _find_rumble_icon()
 231        if rumble_path:
 232            try:
 233                if rumble_path.suffix == ".svg" and HAS_CAIROSVG:
 234                    rumble_data = cairosvg.svg2png(
 235                        url=str(rumble_path),
 236                        output_width=64, output_height=64,
 237                    )
 238                    self._rumble_base_image = Image.open(
 239                        io.BytesIO(rumble_data)).convert("RGBA")
 240                elif rumble_path.suffix != ".svg":
 241                    self._rumble_base_image = Image.open(
 242                        str(rumble_path)).convert("RGBA")
 243            except Exception:
 244                pass  # Non-fatal: fallback icon is generated below
 245        # Fallback: draw a simple rumble icon with PIL if SVG couldn't load
 246        if self._rumble_base_image is None:
 247            self._rumble_base_image = self._make_rumble_fallback(64)
 248
 249        # Load team gear logo for top-right overlay
 250        self._gear_base_image = None
 251        gear_path = _find_gear_icon()
 252        if gear_path:
 253            try:
 254                self._gear_base_image = Image.open(
 255                    str(gear_path)).convert("RGBA")
 256            except Exception:
 257                pass  # Non-fatal: gear overlay is optional decoration
 258
 259    @staticmethod
 260    def _make_rumble_fallback(size: int) -> Image.Image:
 261        """Draw a simple rumble/vibration icon when SVG can't be loaded.
 262
 263        Reproduces the key shapes from rumble.svg: a battery-like body
 264        with horizontal bars and a positive terminal nub.
 265        """
 266        img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
 267        d = ImageDraw.Draw(img)
 268        s = size / 24  # scale factor (SVG viewBox is 24x24)
 269
 270        # Main body (rounded-ish rectangle: x=2..15, y=5..16)
 271        d.rectangle([s * 2, s * 5, s * 15, s * 16], fill=(0, 0, 0, 255))
 272        # Terminal nub (x=17..18, y=8..9.5 and x=17..22, y=9.5..11.5)
 273        d.rectangle([s * 17, s * 8, s * 18, s * 11.5], fill=(0, 0, 0, 255))
 274        d.rectangle([s * 17, s * 9.5, s * 22, s * 11.5], fill=(0, 0, 0, 255))
 275        # Inner bars (white gaps to show charge lines)
 276        for y_top in [7, 9, 11, 13]:
 277            d.rectangle([s * 5, s * y_top, s * 14, s * (y_top + 0.8),],
 278                        fill=(255, 255, 255, 255))
 279        # Base plate (x=3..16, y=17..19)
 280        d.rectangle([s * 3, s * 17, s * 16, s * 19], fill=(0, 0, 0, 255))
 281        return img
 282
 283    def set_bindings(self, bindings: dict[str, list[str]]):
 284        """Update the displayed bindings and redraw."""
 285        self._bindings = dict(bindings)
 286        self._redraw()
 287
 288    def set_show_borders(self, show: bool):
 289        """Toggle visibility of shape outlines and redraw."""
 290        self._show_borders = show
 291        self._redraw()
 292
 293    def set_hide_unassigned(self, hide: bool):
 294        """Toggle hiding of inputs with no bindings and redraw."""
 295        self._hide_unassigned = hide
 296        self._redraw()
 297
 298    def set_drag_cursor(self, dragging: bool):
 299        """Set cross-widget drag state so hover cursor is overridden."""
 300        self._dragging_from_panel = dragging
 301        self._canvas.config(cursor="plus" if dragging else "")
 302
 303    def reset_label_positions(self):
 304        """Clear all custom label positions and redraw at defaults."""
 305        self._custom_label_pos.clear()
 306        self._redraw()
 307
 308    def set_labels_locked(self, locked: bool):
 309        """Lock or unlock label dragging."""
 310        self._labels_locked = locked
 311
 312    # --- Drop target highlighting (for drag-and-drop) ---
 313
 314    def _root_to_canvas(self, x_root: int, y_root: int) -> tuple[int, int]:
 315        """Convert root screen coordinates to canvas-local coordinates."""
 316        return x_root - self._canvas.winfo_rootx(), y_root - self._canvas.winfo_rooty()
 317
 318    def highlight_drop_target(self, x_root: int, y_root: int) -> str | None:
 319        """Highlight the binding box or shape under root coords.
 320
 321        Returns the input name if over a single-input target, None otherwise.
 322        """
 323        self.clear_drop_highlight()
 324        cx, cy = self._root_to_canvas(x_root, y_root)
 325
 326        # Check binding boxes first
 327        box_hit = self._hit_test_box(cx, cy)
 328        if box_hit and box_hit in self._box_items:
 329            box_id = self._box_items[box_hit][0]
 330            coords = self._canvas.coords(box_id)
 331            if len(coords) == 4:
 332                self._drop_highlight_id = self._canvas.create_rectangle(
 333                    coords[0] - 2, coords[1] - 2,
 334                    coords[2] + 2, coords[3] + 2,
 335                    outline="#2266cc", width=3, fill="",
 336                )
 337            return box_hit
 338
 339        # Check shapes
 340        shape_hit = self._hit_test_shape(cx, cy)
 341        if shape_hit and shape_hit.name in self._shape_items:
 342            item_id = self._shape_items[shape_hit.name]
 343            coords = self._canvas.coords(item_id)
 344            if len(coords) == 4:
 345                self._drop_highlight_id = self._canvas.create_rectangle(
 346                    coords[0] - 2, coords[1] - 2,
 347                    coords[2] + 2, coords[3] + 2,
 348                    outline="#2266cc", width=3, fill="",
 349                )
 350            if len(shape_hit.inputs) == 1:
 351                return shape_hit.inputs[0]
 352            # Multi-input shape: caller should handle via get_drop_target
 353            return None
 354
 355        return None
 356
 357    def get_drop_target(self, x_root: int, y_root: int):
 358        """Return (input_name, shape) at root coordinates for drop resolution.
 359
 360        Returns:
 361            (str, None) if over a binding box (direct single-input target)
 362            (str, None) if over a single-input shape
 363            (None, ButtonShape) if over a multi-input shape (caller shows menu)
 364            (None, None) if not over anything
 365        """
 366        cx, cy = self._root_to_canvas(x_root, y_root)
 367
 368        box_hit = self._hit_test_box(cx, cy)
 369        if box_hit:
 370            return box_hit, None
 371
 372        shape_hit = self._hit_test_shape(cx, cy)
 373        if shape_hit:
 374            if len(shape_hit.inputs) == 1:
 375                return shape_hit.inputs[0], None
 376            return None, shape_hit
 377
 378        return None, None
 379
 380    def clear_drop_highlight(self):
 381        """Remove any drop target highlighting."""
 382        if self._drop_highlight_id is not None:
 383            self._canvas.delete(self._drop_highlight_id)
 384            self._drop_highlight_id = None
 385
 386    def dim_incompatible_inputs(self, compatible_names: set[str]):
 387        """Grey out incompatible boxes and highlight compatible ones.
 388
 389        Also shows green outlines on controller button shapes that
 390        contain at least one compatible input.
 391        """
 392        self.clear_dim_overlays()
 393        # Highlight/dim binding boxes
 394        for name, item_ids in self._box_items.items():
 395            if not item_ids:
 396                continue
 397            box_id = item_ids[0]
 398            coords = self._canvas.coords(box_id)
 399            if len(coords) != 4:
 400                continue
 401            if name in compatible_names:
 402                # Green highlight border around compatible inputs
 403                oid = self._canvas.create_rectangle(
 404                    coords[0] - 3, coords[1] - 3,
 405                    coords[2] + 3, coords[3] + 3,
 406                    outline="#33aa33", width=3, fill="",
 407                )
 408                self._dim_overlay_ids.append(oid)
 409            else:
 410                # Solid grey overlay on incompatible inputs
 411                oid = self._canvas.create_rectangle(
 412                    coords[0], coords[1], coords[2], coords[3],
 413                    fill="#bbbbbb", outline="#999999", stipple="gray75",
 414                )
 415                self._dim_overlay_ids.append(oid)
 416
 417        # Green outlines on compatible button shapes
 418        for shape_name, shape in self._shape_map.items():
 419            has_compatible = any(
 420                inp in compatible_names for inp in shape.inputs)
 421            if not has_compatible:
 422                continue
 423            item_id = self._shape_items.get(shape_name)
 424            if item_id is None:
 425                continue
 426            coords = self._canvas.coords(item_id)
 427            if len(coords) != 4:
 428                continue
 429            cx = (coords[0] + coords[2]) / 2
 430            cy = (coords[1] + coords[3]) / 2
 431            hw = (coords[2] - coords[0]) / 2
 432            hh = (coords[3] - coords[1]) / 2
 433            if shape.shape in ("circle", "pill"):
 434                oid = self._canvas.create_oval(
 435                    cx - hw, cy - hh, cx + hw, cy + hh,
 436                    outline="#33aa33", width=3, fill="",
 437                )
 438            else:
 439                oid = self._canvas.create_rectangle(
 440                    cx - hw, cy - hh, cx + hw, cy + hh,
 441                    outline="#33aa33", width=3, fill="",
 442                )
 443            self._dim_overlay_ids.append(oid)
 444
 445    def clear_dim_overlays(self):
 446        """Remove drag compatibility overlays."""
 447        for oid in self._dim_overlay_ids:
 448            self._canvas.delete(oid)
 449        self._dim_overlay_ids.clear()
 450
 451    def _on_resize(self, event):
 452        self._redraw()
 453
 454    def _redraw(self):
 455        """Redraw the entire canvas: image, shapes, lines, and binding boxes."""
 456        self._canvas.delete("all")
 457        self._box_items.clear()
 458        self._line_items.clear()
 459        self._shape_items.clear()
 460        self._shape_map.clear()
 461        self._hover_shape = None
 462        self._rumble_label_icons.clear()
 463        self._label_icon_refs.clear()
 464
 465        canvas_w = self._canvas.winfo_width()
 466        canvas_h = self._canvas.winfo_height()
 467        if canvas_w < 10 or canvas_h < 10:
 468            return
 469
 470        # Scale image to fit canvas with proportional padding for labels.
 471        # Reserve a fraction of canvas width for label columns on each side
 472        # so the controller image fills more space as the window shrinks.
 473        pad_frac = 0.12  # fraction of canvas width reserved per side
 474        pad_x = max(50, int(canvas_w * pad_frac))
 475        pad_top = max(20, int(canvas_h * 0.04))
 476        pad_bot = max(50, int(canvas_h * 0.10))  # extra bottom room for Mac DPI scaling
 477        avail_w = canvas_w - 2 * pad_x
 478        avail_h = canvas_h - pad_top - pad_bot
 479
 480        scale = min(avail_w / self._img_width, avail_h / self._img_height)
 481        scale = max(scale, 0.08)
 482        scale = min(scale, 1.5)
 483
 484        new_w = int(self._img_width * scale)
 485        new_h = int(self._img_height * scale)
 486
 487        resized = self._base_image.resize((new_w, new_h), Image.LANCZOS)
 488        self._tk_image = ImageTk.PhotoImage(resized)
 489
 490        # Center horizontally, shift down slightly to leave room for
 491        # rumble icons above the controller and reduce bottom whitespace.
 492        img_x = canvas_w // 2
 493        img_y = int(canvas_h * 0.51)
 494        self._bg_image_id = self._canvas.create_image(
 495            img_x, img_y, image=self._tk_image, anchor=tk.CENTER)
 496
 497        # Store rendered image bounds for fractional coordinate mapping
 498        self._img_left = img_x - new_w // 2
 499        self._img_top = img_y - new_h // 2
 500        self._rendered_w = new_w
 501        self._rendered_h = new_h
 502
 503        # Compute scaled label sizes based on image scale
 504        self._compute_scaled_sizes(scale)
 505
 506        # Draw button outlines on the controller
 507        for shape in XBOX_SHAPES:
 508            self._draw_shape(shape)
 509
 510        # Draw lines and binding boxes for each input
 511        self._group_stack_index: dict[str, int] = {}
 512        self._group_stack_origin: dict[str, tuple[float, float]] = {}
 513        for inp in XBOX_INPUTS:
 514            if self._hide_unassigned and not self._bindings.get(inp.name):
 515                continue
 516            self._draw_input(inp)
 517
 518        # Draw connector bars for grouped labels (D-pad, sticks)
 519        self._draw_connector_groups()
 520
 521        # Draw rumble icons below each rumble label box
 522        self._draw_rumble_icons()
 523
 524        # Draw team logo in top-right corner
 525        self._draw_gear_logo(canvas_w)
 526
 527        # Re-apply selection highlight after redraw
 528        if self._selected_input and self._selected_input in self._line_items:
 529            self._canvas.itemconfig(
 530                self._line_items[self._selected_input],
 531                fill=LINE_SELECTED_COLOR, width=2)
 532
 533    # Label sizes are designed for scale ~0.4-0.5 (typical canvas).
 534    # This multiplier bumps them up so they're readable at normal zoom.
 535    _LABEL_SCALE_BOOST = 1.75
 536
 537    def _compute_scaled_sizes(self, img_scale: float):
 538        """Recompute all label dimensions from the current image scale.
 539
 540        Reference sizes are defined for scale=1.0 (image at native res).
 541        Everything scales linearly so labels shrink/grow with the canvas.
 542        """
 543        s = max(img_scale * self._LABEL_SCALE_BOOST * self._dpi_scale, 0.25)
 544        self._s = s
 545        self._box_w = max(100, int(_REF_BOX_WIDTH * s))
 546        self._box_h = max(22, int(_REF_BOX_HEIGHT * s))
 547        self._box_pad = max(3, int(_REF_BOX_PAD * s))
 548        self._label_font_size = max(8, int(_REF_LABEL_FONT * s))
 549        self._action_font_size = max(9, int(_REF_ACTION_FONT * s))
 550        self._plus_font_size = max(12, int(_REF_PLUS_FONT * s))
 551        self._label_y_offset = max(12, int(_REF_LABEL_Y * s))
 552        self._action_step = max(14, int(_REF_ACTION_STEP * s))
 553        self._icon_size = max(10, self._box_h - int(8 * s))
 554
 555    def _map_frac(self, frac_x: float, frac_y: float) -> tuple[float, float]:
 556        """Map fractional image coordinates (0-1) to canvas pixel position."""
 557        return (
 558            self._img_left + frac_x * self._rendered_w,
 559            self._img_top + frac_y * self._rendered_h,
 560        )
 561
 562    def _unmap_to_img(self, cx: float, cy: float) -> tuple[int, int]:
 563        """Convert canvas pixel position back to source image pixel coords."""
 564        frac_x = (cx - self._img_left) / self._rendered_w if self._rendered_w else 0
 565        frac_y = (cy - self._img_top) / self._rendered_h if self._rendered_h else 0
 566        return int(frac_x * _IMG_W), int(frac_y * _IMG_H)
 567
 568    def _map_label(self, inp: InputCoord, canvas_w: int,
 569                   canvas_h: int) -> tuple[float, float]:
 570        """Map label positions.  Uses custom dragged position if available,
 571        otherwise places at the left or right canvas edge."""
 572        bw = self._box_w
 573        bh = self._box_h
 574        if inp.name in self._custom_label_pos:
 575            img_x, img_y = self._custom_label_pos[inp.name]
 576            lx = self._img_left + (img_x / _IMG_W) * self._rendered_w
 577            ly = self._img_top + (img_y / _IMG_H) * self._rendered_h
 578            lx = max(5, min(lx, canvas_w - bw - 5))
 579            ly = max(5, min(ly, canvas_h - bh - 5))
 580            return lx, ly
 581
 582        lx = self._img_left + inp.label_x * self._rendered_w
 583        ly = self._img_top + inp.label_y * self._rendered_h
 584        lx = max(5, min(lx, canvas_w - bw - 5))
 585        ly = max(5, min(ly, canvas_h - bh - 5))
 586        return lx, ly
 587
 588    # --- Connector bars for grouped inputs ---
 589
 590    # Groups of inputs that share a single leader line + vertical bar.
 591    # Each entry: (prefix to match, anchor input for the leader line)
 592    _CONNECTOR_GROUPS = [
 593        ("pov_", "pov_right"),           # D-pad
 594        ("left_stick", "left_stick_x"),  # Left stick
 595        ("right_stick", "right_stick_x"),  # Right stick
 596    ]
 597
 598    def _get_drag_group(self, name: str) -> list[str]:
 599        """Return all names in the same connector group, or just [name]."""
 600        for prefix, _ in self._CONNECTOR_GROUPS:
 601            if name.startswith(prefix):
 602                return [n for n in self._box_items if n.startswith(prefix)]
 603        return [name]
 604
 605    def _draw_connector_groups(self):
 606        """Draw connector bars for grouped label columns.
 607
 608        Each group gets a vertical bar along the right edge of its label
 609        column, with a single leader line from the shared anchor point
 610        to the bar midpoint.  Items are stored in ``_connector_group_items``
 611        so they can be updated during drag.
 612        """
 613        from .layout_coords import XBOX_INPUT_MAP
 614
 615        self._connector_group_items: dict[str, tuple[int, int]] = {}
 616
 617        for prefix, anchor_name in self._CONNECTOR_GROUPS:
 618            boxes = []
 619            for name, item_ids in self._box_items.items():
 620                if name.startswith(prefix) and item_ids:
 621                    coords = self._canvas.coords(item_ids[0])
 622                    if len(coords) == 4:
 623                        boxes.append(coords)
 624            if not boxes:
 625                continue
 626
 627            right_x = max(c[2] for c in boxes)
 628            top_y = min(c[1] for c in boxes)
 629            bottom_y = max(c[3] for c in boxes)
 630            bar_x = right_x + 6
 631
 632            # Vertical bar
 633            bar_id = self._canvas.create_line(
 634                bar_x, top_y, bar_x, bottom_y,
 635                fill=LINE_COLOR, width=3,
 636            )
 637
 638            # Leader line from anchor to bar midpoint
 639            line_id = None
 640            anchor_inp = XBOX_INPUT_MAP.get(anchor_name)
 641            if anchor_inp:
 642                ax, ay = self._map_frac(anchor_inp.anchor_x,
 643                                        anchor_inp.anchor_y)
 644                bar_mid_y = (top_y + bottom_y) / 2
 645                line_id = self._canvas.create_line(
 646                    ax, ay, bar_x, bar_mid_y,
 647                    fill=LINE_COLOR, width=1,
 648                )
 649
 650            self._connector_group_items[prefix] = (bar_id, line_id)
 651
 652    def _update_connector_group(self, prefix: str):
 653        """Reposition the connector bar and leader line for a group."""
 654        from .layout_coords import XBOX_INPUT_MAP
 655
 656        items = self._connector_group_items.get(prefix)
 657        if not items:
 658            return
 659        bar_id, line_id = items
 660
 661        boxes = []
 662        for name, item_ids in self._box_items.items():
 663            if name.startswith(prefix) and item_ids:
 664                coords = self._canvas.coords(item_ids[0])
 665                if len(coords) == 4:
 666                    boxes.append(coords)
 667        if not boxes:
 668            return
 669
 670        right_x = max(c[2] for c in boxes)
 671        top_y = min(c[1] for c in boxes)
 672        bottom_y = max(c[3] for c in boxes)
 673        bar_x = right_x + 6
 674
 675        self._canvas.coords(bar_id, bar_x, top_y, bar_x, bottom_y)
 676
 677        if line_id:
 678            anchor_name = None
 679            for p, a in self._CONNECTOR_GROUPS:
 680                if p == prefix:
 681                    anchor_name = a
 682                    break
 683            anchor_inp = XBOX_INPUT_MAP.get(anchor_name) if anchor_name else None
 684            if anchor_inp:
 685                ax, ay = self._map_frac(anchor_inp.anchor_x,
 686                                        anchor_inp.anchor_y)
 687                bar_mid_y = (top_y + bottom_y) / 2
 688                self._canvas.coords(line_id, ax, ay, bar_x, bar_mid_y)
 689
 690    # --- Rumble icons ---
 691
 692    def _draw_rumble_icons(self):
 693        """Draw a rumble icon on the controller at each rumble anchor."""
 694        from .layout_coords import XBOX_INPUT_MAP
 695        if not self._rumble_base_image:
 696            return
 697        icon_size = self._box_w // 4
 698        resized = self._rumble_base_image.resize(
 699            (icon_size, icon_size), Image.LANCZOS)
 700
 701        for name in ["rumble_left", "rumble_both", "rumble_right"]:
 702            inp = XBOX_INPUT_MAP.get(name)
 703            if not inp:
 704                continue
 705            cx, cy = self._map_frac(inp.anchor_x, inp.anchor_y)
 706            tk_icon = ImageTk.PhotoImage(resized)
 707            self._rumble_label_icons.append(tk_icon)
 708            self._canvas.create_image(
 709                cx, cy, image=tk_icon, anchor=tk.CENTER)
 710
 711    # --- Gear logo ---
 712
 713    def _draw_gear_logo(self, canvas_w: int):
 714        """Draw the team gear logo in the top-right corner of the canvas."""
 715        if not self._gear_base_image:
 716            return
 717        # ~96px (approx 1 inch at standard DPI)
 718        logo_size = 96
 719        resized = self._gear_base_image.resize(
 720            (logo_size, logo_size), Image.LANCZOS)
 721        self._gear_tk_image = ImageTk.PhotoImage(resized)
 722        margin = 8
 723        self._canvas.create_image(
 724            canvas_w - margin, margin,
 725            image=self._gear_tk_image, anchor=tk.NE)
 726
 727    # --- Shape drawing ---
 728
 729    def _draw_shape(self, shape: ButtonShape):
 730        """Draw a bold outline on the controller for a button/stick/trigger."""
 731        cx, cy = self._map_frac(shape.center_x, shape.center_y)
 732        hw = shape.width * self._rendered_w / 2
 733        hh = shape.height * self._rendered_h / 2
 734
 735        outline = SHAPE_OUTLINE_COLOR if self._show_borders else ""
 736        width = SHAPE_OUTLINE_WIDTH if self._show_borders else 0
 737
 738        if shape.shape == "circle":
 739            item = self._canvas.create_oval(
 740                cx - hw, cy - hh, cx + hw, cy + hh,
 741                outline=outline, width=width,
 742                fill="",
 743            )
 744        elif shape.shape == "pill":
 745            # Rounded rectangle approximation using an oval
 746            item = self._canvas.create_oval(
 747                cx - hw, cy - hh, cx + hw, cy + hh,
 748                outline=outline, width=width,
 749                fill="",
 750            )
 751        else:  # rect
 752            item = self._canvas.create_rectangle(
 753                cx - hw, cy - hh, cx + hw, cy + hh,
 754                outline=outline, width=width,
 755                fill="",
 756            )
 757
 758        self._shape_items[shape.name] = item
 759        self._shape_map[shape.name] = shape
 760
 761    # --- Binding box drawing ---
 762
 763    # D-pad inputs are stacked in canvas-pixel space so spacing is
 764    # consistent regardless of zoom level.
 765    _STACK_GROUPS = {"pov_"}
 766
 767    def _draw_input(self, inp: InputCoord):
 768        """Draw a single input's leader line and binding box."""
 769        canvas_w = self._canvas.winfo_width()
 770        canvas_h = self._canvas.winfo_height()
 771
 772        # Scaled sizes
 773        bw = self._box_w
 774        bh = self._box_h
 775        bp = self._box_pad
 776        lf = self._label_font_size
 777        af = self._action_font_size
 778        pf = self._plus_font_size
 779        ly_off = self._label_y_offset
 780        a_step = self._action_step
 781        ic_sz = self._icon_size
 782
 783        ax, ay = self._map_frac(inp.anchor_x, inp.anchor_y)
 784        lx, ly = self._map_label(inp, canvas_w, canvas_h)
 785
 786        # Stack grouped labels at fixed canvas-pixel intervals
 787        for prefix in self._STACK_GROUPS:
 788            if inp.name.startswith(prefix):
 789                if prefix not in self._group_stack_origin:
 790                    self._group_stack_origin[prefix] = (lx, ly)
 791                    self._group_stack_index[prefix] = 0
 792                else:
 793                    idx = self._group_stack_index[prefix] + 1
 794                    self._group_stack_index[prefix] = idx
 795                    origin_x, origin_y = self._group_stack_origin[prefix]
 796                    lx = origin_x
 797                    ly = origin_y + idx * bh
 798                break
 799
 800        box_cx = lx + bw / 2
 801        box_cy = ly + bh / 2
 802
 803        # Leader line — skip for grouped inputs (connector bar drawn separately)
 804        is_grouped = (inp.name.startswith("pov_")
 805                      or inp.name.startswith("left_stick")
 806                      or inp.name.startswith("right_stick"))
 807        if not is_grouped:
 808            line_id = self._canvas.create_line(
 809                ax, ay, box_cx, box_cy,
 810                fill=LINE_COLOR, width=1,
 811            )
 812            self._line_items[inp.name] = line_id
 813
 814        # Assigned actions — D-pad single-line, sticks max 2
 815        actions = self._bindings.get(inp.name, [])
 816        is_dpad = inp.name.startswith("pov_")
 817        is_stick = (inp.name.startswith("left_stick")
 818                    or inp.name.startswith("right_stick"))
 819        all_actions = actions
 820        if is_dpad:
 821            actions = all_actions[:1]
 822        elif is_stick:
 823            actions = all_actions[:2]
 824        has_actions = len(all_actions) > 0
 825        fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
 826        if is_dpad:
 827            total_height = bh
 828        elif has_actions:
 829            total_height = ly_off + len(actions) * a_step
 830        else:
 831            total_height = bh
 832
 833        # Box background
 834        box_id = self._canvas.create_rectangle(
 835            lx, ly, lx + bw, ly + total_height,
 836            fill=fill, outline=BOX_OUTLINE, width=1,
 837        )
 838
 839        axis_tag = None
 840        if inp.name.endswith("_x"):
 841            axis_tag = "X"
 842        elif inp.name.endswith("_y"):
 843            axis_tag = "Y"
 844
 845        items = [box_id]
 846
 847        # Input icon (scaled)
 848        text_offset = bp
 849        if self._icon_loader:
 850            icon = self._icon_loader.get_tk_icon(inp.name, ic_sz)
 851            if icon:
 852                self._label_icon_refs.append(icon)
 853                icon_id = self._canvas.create_image(
 854                    lx + bp, ly + 1, image=icon, anchor=tk.NW)
 855                items.append(icon_id)
 856                text_offset = bp + ic_sz + max(2, int(4 * self._s))
 857
 858        # Input label
 859        label_color = (AXIS_INDICATOR_COLORS[axis_tag]
 860                       if axis_tag else "#555555")
 861
 862        if is_dpad:
 863            # D-pad compact: icon + label + action on one line
 864            line_text = inp.display_name
 865            if has_actions:
 866                line_text += " : " + actions[0]
 867            label_id = self._canvas.create_text(
 868                lx + text_offset, ly + 2,
 869                text=line_text, anchor=tk.NW,
 870                font=("Arial", lf, "bold" if has_actions else ""),
 871                fill=ASSIGNED_COLOR if has_actions else label_color,
 872            )
 873            items.append(label_id)
 874            # "+" indicator when extra bindings are hidden
 875            if has_actions and len(all_actions) > 1:
 876                plus_id = self._canvas.create_text(
 877                    lx + bw + max(2, int(4 * self._s)), ly - int(4 * self._s),
 878                    text="+", anchor=tk.NW,
 879                    font=("Arial", pf, "bold"), fill=ASSIGNED_COLOR,
 880                )
 881                items.append(plus_id)
 882        else:
 883            label_id = self._canvas.create_text(
 884                lx + text_offset, ly + 2,
 885                text=inp.display_name, anchor=tk.NW,
 886                font=("Arial", lf), fill=label_color,
 887            )
 888            items.append(label_id)
 889
 890            # Action names or unassigned text
 891            if has_actions:
 892                for i, action in enumerate(actions):
 893                    txt_id = self._canvas.create_text(
 894                        lx + bp, ly + ly_off + i * a_step,
 895                        text=action, anchor=tk.NW,
 896                        font=("Arial", af, "bold"), fill=ASSIGNED_COLOR,
 897                    )
 898                    items.append(txt_id)
 899                # "+" when actions are truncated (sticks capped at 2)
 900                if len(all_actions) > len(actions):
 901                    plus_id = self._canvas.create_text(
 902                        lx + bw + max(2, int(4 * self._s)),
 903                        ly - int(4 * self._s),
 904                        text="+", anchor=tk.NW,
 905                        font=("Arial", pf, "bold"), fill=ASSIGNED_COLOR,
 906                    )
 907                    items.append(plus_id)
 908            else:
 909                txt_id = self._canvas.create_text(
 910                    lx + bp, ly + ly_off,
 911                    text=UNASSIGNED_TEXT, anchor=tk.NW,
 912                    font=("Arial", af), fill=UNASSIGNED_COLOR,
 913                )
 914                items.append(txt_id)
 915
 916        self._box_items[inp.name] = items
 917
 918    # --- Hit testing ---
 919
 920    def _hit_test_box(self, x: float, y: float) -> str | None:
 921        """Return input name if (x, y) is inside a binding box."""
 922        for name, item_ids in self._box_items.items():
 923            if not item_ids:
 924                continue
 925            box_id = item_ids[0]
 926            coords = self._canvas.coords(box_id)
 927            if len(coords) == 4:
 928                x1, y1, x2, y2 = coords
 929                if x1 <= x <= x2 and y1 <= y <= y2:
 930                    return name
 931        return None
 932
 933    def _hit_test_shape(self, x: float, y: float) -> ButtonShape | None:
 934        """Return the ButtonShape if (x, y) is inside a controller outline."""
 935        for shape_name, item_id in self._shape_items.items():
 936            coords = self._canvas.coords(item_id)
 937            if len(coords) == 4:
 938                x1, y1, x2, y2 = coords
 939                shape = self._shape_map[shape_name]
 940                if shape.shape == "circle":
 941                    # Ellipse hit test
 942                    cx = (x1 + x2) / 2
 943                    cy = (y1 + y2) / 2
 944                    rx = (x2 - x1) / 2
 945                    ry = (y2 - y1) / 2
 946                    if rx > 0 and ry > 0:
 947                        if ((x - cx) ** 2 / rx ** 2
 948                                + (y - cy) ** 2 / ry ** 2) <= 1:
 949                            return shape
 950                else:
 951                    if x1 <= x <= x2 and y1 <= y <= y2:
 952                        return shape
 953        return None
 954
 955    # --- Selection ---
 956
 957    def clear_selection(self):
 958        """Clear the selected input, restoring line to default color."""
 959        self._select_input(None)
 960
 961    def _select_input(self, name: str | None):
 962        """Set the selected input, updating line colors."""
 963        # Deselect previous
 964        if (self._selected_input
 965                and self._selected_input in self._line_items):
 966            self._canvas.itemconfig(
 967                self._line_items[self._selected_input],
 968                fill=LINE_COLOR, width=1)
 969        # Select new
 970        self._selected_input = name
 971        if name and name in self._line_items:
 972            line_id = self._line_items[name]
 973            self._canvas.itemconfig(
 974                line_id, fill=LINE_SELECTED_COLOR, width=2)
 975            # Ensure line is visible above the background image
 976            if hasattr(self, '_bg_image_id'):
 977                self._canvas.tag_raise(line_id, self._bg_image_id)
 978
 979    # --- Drag helpers ---
 980
 981    def _move_box(self, name: str, dx: float, dy: float):
 982        """Move all canvas items for a binding box by (dx, dy)."""
 983        for item_id in self._box_items.get(name, []):
 984            self._canvas.move(item_id, dx, dy)
 985
 986    def _update_line_for_box(self, name: str):
 987        """Recreate the leader line to the current box center position."""
 988        if name not in self._line_items or name not in self._box_items:
 989            return
 990        from .layout_coords import XBOX_INPUT_MAP
 991        inp = XBOX_INPUT_MAP.get(name)
 992        if not inp:
 993            return
 994
 995        # Anchor point on the controller image
 996        ax, ay = self._map_frac(inp.anchor_x, inp.anchor_y)
 997
 998        # Current box center
 999        box_coords = self._canvas.coords(self._box_items[name][0])
1000        if len(box_coords) != 4:
1001            return
1002        box_cx = (box_coords[0] + box_coords[2]) / 2
1003        box_cy = (box_coords[1] + box_coords[3]) / 2
1004
1005        # Delete old line and create new
1006        old_line = self._line_items[name]
1007        self._canvas.delete(old_line)
1008
1009        is_selected = (name == self._selected_input)
1010        color = LINE_SELECTED_COLOR if is_selected else LINE_COLOR
1011        width = 2 if is_selected else 1
1012
1013        new_line = self._canvas.create_line(
1014            ax, ay, box_cx, box_cy,
1015            fill=color, width=width,
1016        )
1017        self._line_items[name] = new_line
1018        # Place line above the background image but below boxes/shapes
1019        self._canvas.tag_raise(new_line, self._bg_image_id)
1020
1021    # --- Tooltip ---
1022
1023    @staticmethod
1024    def _input_description(inp: InputCoord) -> str:
1025        """Build a one-line type description for an input."""
1026        if inp.input_type == "axis":
1027            if inp.name.endswith("_x"):
1028                return "X Axis float [-1 (Left), 1 (Right)]"
1029            elif inp.name.endswith("_y"):
1030                return "Y Axis float [-1 (Up), 1 (Down)]"
1031            else:
1032                return "Axis float [0 (Released), 1 (Pressed)]"
1033        elif inp.name.startswith("pov_"):
1034            pov_degrees = {
1035                "pov_up": 0, "pov_up_right": 45,
1036                "pov_right": 90, "pov_down_right": 135,
1037                "pov_down": 180, "pov_down_left": 225,
1038                "pov_left": 270, "pov_up_left": 315,
1039            }
1040            deg = pov_degrees.get(inp.name, "?")
1041            return f"D-Pad [{deg}\u00b0, Button]"
1042        elif inp.input_type == "output":
1043            return "Output float [0.0 (Off), 1.0 (Max)]"
1044        else:
1045            return "Button [Boolean]"
1046
1047    def _build_tooltip_text(self, shape: ButtonShape) -> str:
1048        """Build multi-line tooltip text for a controller shape."""
1049        from .layout_coords import XBOX_INPUT_MAP
1050
1051        # Title from the shape's first input's common name
1052        title_map = {
1053            "ls": "Left Analog Stick", "rs": "Right Analog Stick",
1054            "lt": "Left Trigger", "rt": "Right Trigger",
1055            "lb": "Left Bumper", "rb": "Right Bumper",
1056            "a": "A Button", "b": "B Button",
1057            "x": "X Button", "y": "Y Button",
1058            "back": "Back Button", "start": "Start Button",
1059            "dpad": "D-Pad",
1060            "rumble_l": "Left Rumble", "rumble_b": "Both Rumble",
1061            "rumble_r": "Right Rumble",
1062        }
1063        title = title_map.get(shape.name, shape.name)
1064        lines = [title]
1065        for input_name in shape.inputs:
1066            inp = XBOX_INPUT_MAP.get(input_name)
1067            if inp:
1068                lines.append(f"  {inp.display_name}: {self._input_description(inp)}")
1069        return "\n".join(lines)
1070
1071    def _show_tooltip(self, x_root: int, y_root: int, text: str):
1072        """Show or update the tooltip near the cursor."""
1073        if self._tooltip:
1074            label = self._tooltip.winfo_children()[0]
1075            label.config(text=text)
1076            self._tooltip.geometry(f"+{x_root + 15}+{y_root + 10}")
1077            self._tooltip.deiconify()
1078            return
1079
1080        self._tooltip = tw = tk.Toplevel(self)
1081        tw.wm_overrideredirect(True)
1082        tw.wm_geometry(f"+{x_root + 15}+{y_root + 10}")
1083        tw.attributes("-topmost", True)
1084        label = tk.Label(
1085            tw, text=text, justify=tk.LEFT,
1086            background="#ffffe0", foreground="#222222",
1087            relief=tk.SOLID, borderwidth=1,
1088            font=("Arial", 9), padx=6, pady=4,
1089        )
1090        label.pack()
1091
1092    def _hide_tooltip(self):
1093        """Hide the tooltip."""
1094        if self._tooltip:
1095            self._tooltip.withdraw()
1096
1097    # --- Event handlers ---
1098
1099    def _on_mouse_move(self, event):
1100        """Highlight binding boxes and controller shapes on hover."""
1101        # Skip hover highlighting while dragging
1102        if self._dragging:
1103            return
1104
1105        # Check binding boxes first (they're on top)
1106        box_hit = self._hit_test_box(event.x, event.y)
1107        shape_hit = self._hit_test_shape(event.x, event.y)
1108
1109        # Update binding box highlight
1110        if box_hit != self._hover_input:
1111            # Unhighlight previous hover box
1112            if self._hover_input and self._hover_input in self._box_items:
1113                items = self._box_items[self._hover_input]
1114                if items:
1115                    has_actions = bool(
1116                        self._bindings.get(self._hover_input))
1117                    fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
1118                    self._canvas.itemconfig(items[0], fill=fill)
1119                # Restore line color unless this input is selected
1120                if (self._hover_input != self._selected_input
1121                        and self._hover_input in self._line_items):
1122                    self._canvas.itemconfig(
1123                        self._line_items[self._hover_input],
1124                        fill=LINE_COLOR, width=1)
1125
1126            # Highlight new hover box
1127            if box_hit and box_hit in self._box_items:
1128                items = self._box_items[box_hit]
1129                if items:
1130                    self._canvas.itemconfig(items[0], fill=BOX_FILL_HOVER)
1131                # Turn line red on hover
1132                if box_hit in self._line_items:
1133                    self._canvas.itemconfig(
1134                        self._line_items[box_hit],
1135                        fill=LINE_SELECTED_COLOR, width=2)
1136
1137            self._hover_input = box_hit
1138
1139            # Notify parent of hovered input change
1140            if self._on_hover_input:
1141                self._on_hover_input(box_hit)
1142
1143        # Update shape highlight
1144        new_shape_name = shape_hit.name if shape_hit else None
1145        if new_shape_name != self._hover_shape:
1146            # Unhighlight previous shape
1147            if self._hover_shape and self._hover_shape in self._shape_items:
1148                item_id = self._shape_items[self._hover_shape]
1149                rest_outline = SHAPE_OUTLINE_COLOR if self._show_borders else ""
1150                rest_width = SHAPE_OUTLINE_WIDTH if self._show_borders else 0
1151                self._canvas.itemconfig(
1152                    item_id, outline=rest_outline, width=rest_width)
1153
1154            # Highlight new shape (always show on hover)
1155            if new_shape_name and new_shape_name in self._shape_items:
1156                item_id = self._shape_items[new_shape_name]
1157                self._canvas.itemconfig(
1158                    item_id, outline="#2266aa", width=SHAPE_OUTLINE_WIDTH + 1.5)
1159
1160            self._hover_shape = new_shape_name
1161
1162            # Notify parent of hovered shape change
1163            if self._on_hover_shape:
1164                if shape_hit:
1165                    self._on_hover_shape(list(shape_hit.inputs))
1166                else:
1167                    self._on_hover_shape(None)
1168
1169        # Tooltip for shapes
1170        if shape_hit and not box_hit:
1171            text = self._build_tooltip_text(shape_hit)
1172            self._show_tooltip(event.x_root, event.y_root, text)
1173        else:
1174            self._hide_tooltip()
1175
1176        # Cursor (don't override plus cursor during cross-widget drag)
1177        if not self._dragging_from_panel:
1178            if box_hit or shape_hit:
1179                self._canvas.config(cursor="hand2")
1180            else:
1181                self._canvas.config(cursor="")
1182
1183        # Report image-space coordinates to parent
1184        if self._on_mouse_coord and hasattr(self, '_rendered_w'):
1185            img_x, img_y = self._unmap_to_img(event.x, event.y)
1186            self._on_mouse_coord(img_x, img_y)
1187
1188    def _on_leave(self, event):
1189        """Hide tooltip when the cursor leaves the canvas."""
1190        self._hide_tooltip()
1191
1192    def _on_press(self, event):
1193        """Handle mouse button press — start potential drag or shape click."""
1194        box_hit = self._hit_test_box(event.x, event.y)
1195        if box_hit:
1196            self._dragging = box_hit
1197            self._drag_start = (event.x, event.y)
1198            self._did_drag = False
1199            self._select_input(box_hit)
1200            return
1201
1202        # If not on a box, clear drag state
1203        self._dragging = None
1204        self._did_drag = False
1205
1206    def _on_drag(self, event):
1207        """Handle mouse drag — move binding box if dragging."""
1208        if not self._dragging or self._labels_locked:
1209            return
1210
1211        dx = event.x - self._drag_start[0]
1212        dy = event.y - self._drag_start[1]
1213
1214        if not self._did_drag:
1215            dist = math.hypot(dx, dy)
1216            if dist < _DRAG_THRESHOLD:
1217                return
1218            self._did_drag = True
1219            self._canvas.config(cursor="fleur")
1220
1221        # Move the box items — group drag for connected labels
1222        group_names = self._get_drag_group(self._dragging)
1223        for gname in group_names:
1224            self._move_box(gname, dx, dy)
1225            self._update_line_for_box(gname)
1226
1227        # Update connector bar/line for the dragged group
1228        for prefix, _ in self._CONNECTOR_GROUPS:
1229            if self._dragging.startswith(prefix):
1230                self._update_connector_group(prefix)
1231                break
1232
1233        self._drag_start = (event.x, event.y)
1234
1235    def _on_release(self, event):
1236        """Handle mouse button release — finish drag or fire click."""
1237        name = self._dragging
1238        self._dragging = None
1239
1240        if name and self._did_drag:
1241            # Drag finished — save positions for all group members
1242            for gname in self._get_drag_group(name):
1243                box_items = self._box_items.get(gname, [])
1244                if box_items:
1245                    coords = self._canvas.coords(box_items[0])
1246                    if len(coords) == 4:
1247                        lx, ly = coords[0], coords[1]
1248                        img_x, img_y = self._unmap_to_img(lx, ly)
1249                        self._custom_label_pos[gname] = (img_x, img_y)
1250                        if self._on_label_moved:
1251                            self._on_label_moved(gname, img_x, img_y)
1252            self._canvas.config(cursor="hand2")
1253            self._did_drag = False
1254            return
1255
1256        self._did_drag = False
1257
1258        # Was a click (no drag) — handle binding click or shape click
1259        if name:
1260            # Clicked on a binding box
1261            if self._on_binding_click:
1262                self._on_binding_click(name)
1263            return
1264
1265        # Check shapes (click wasn't on a box)
1266        shape_hit = self._hit_test_shape(event.x, event.y)
1267        if shape_hit and self._on_binding_click:
1268            if len(shape_hit.inputs) == 1:
1269                self._select_input(shape_hit.inputs[0])
1270                self._on_binding_click(shape_hit.inputs[0])
1271            else:
1272                self._show_input_menu(event, shape_hit)
1273
1274    def _on_right_click(self, event):
1275        """Show a context menu for clearing actions on right-click.
1276
1277        Works on binding boxes (labels) and controller shapes (buttons).
1278        Shows individual action removal items plus Clear All.
1279        """
1280        box_hit = self._hit_test_box(event.x, event.y)
1281        if box_hit:
1282            self._show_binding_context_menu(event, box_hit)
1283            return
1284
1285        shape_hit = self._hit_test_shape(event.x, event.y)
1286        if shape_hit:
1287            self._show_shape_context_menu(event, shape_hit)
1288
1289    def _show_binding_context_menu(self, event, input_name: str):
1290        """Context menu for a binding box: remove individual actions + clear all."""
1291        self._select_input(input_name)
1292        actions = self._bindings.get(input_name, [])
1293        menu = tk.Menu(self._canvas, tearoff=0)
1294
1295        if actions:
1296            for action in actions:
1297                menu.add_command(
1298                    label=f"Remove: {action}",
1299                    command=lambda n=input_name, a=action:
1300                        self._on_action_remove(n, a)
1301                        if self._on_action_remove else None,
1302                )
1303            menu.add_separator()
1304            menu.add_command(
1305                label="Clear All",
1306                command=lambda n=input_name: self._on_binding_clear(n)
1307                        if self._on_binding_clear else None,
1308            )
1309        else:
1310            menu.add_command(label="(no actions bound)", state=tk.DISABLED)
1311
1312        menu.tk_popup(event.x_root, event.y_root)
1313
1314    def _show_shape_context_menu(self, event, shape: ButtonShape):
1315        """Context menu for a controller shape: remove individual actions + clear all."""
1316        from .layout_coords import XBOX_INPUT_MAP
1317
1318        menu = tk.Menu(self._canvas, tearoff=0)
1319        has_any = False
1320
1321        for input_name in shape.inputs:
1322            actions = self._bindings.get(input_name, [])
1323            if not actions:
1324                continue
1325            has_any = True
1326            inp = XBOX_INPUT_MAP.get(input_name)
1327            display = inp.display_name if inp else input_name
1328            for action in actions:
1329                menu.add_command(
1330                    label=f"Remove: {action} ({display})",
1331                    command=lambda n=input_name, a=action:
1332                        self._on_action_remove(n, a)
1333                        if self._on_action_remove else None,
1334                )
1335
1336        if has_any:
1337            menu.add_separator()
1338            menu.add_command(
1339                label="Clear All",
1340                command=lambda: self._clear_shape_bindings(shape),
1341            )
1342        else:
1343            menu.add_command(label="(no actions bound)", state=tk.DISABLED)
1344
1345        menu.tk_popup(event.x_root, event.y_root)
1346
1347    def _clear_shape_bindings(self, shape: ButtonShape):
1348        """Clear bindings for all inputs of a shape."""
1349        if not self._on_binding_clear:
1350            return
1351        for input_name in shape.inputs:
1352            if self._bindings.get(input_name):
1353                self._on_binding_clear(input_name)
1354
1355    def _show_input_menu(self, event, shape: ButtonShape):
1356        """Show a context menu to pick which input to configure
1357        when a shape maps to multiple inputs (e.g., stick X/Y/button)."""
1358        from .layout_coords import XBOX_INPUT_MAP
1359
1360        menu = tk.Menu(self._canvas, tearoff=0)
1361        for input_name in shape.inputs:
1362            inp = XBOX_INPUT_MAP.get(input_name)
1363            display = inp.display_name if inp else input_name
1364            # Capture input_name in the lambda default arg
1365            menu.add_command(
1366                label=display,
1367                command=lambda n=input_name: self._on_binding_click(n),
1368            )
1369        menu.tk_popup(event.x_root, event.y_root)

Displays the Xbox controller with interactive binding boxes and clickable button outlines.

ControllerCanvas( parent, on_binding_click=None, on_binding_clear=None, on_mouse_coord=None, on_label_moved=None, on_hover_input=None, on_hover_shape=None, on_action_remove=None, label_positions=None, icon_loader=None)
110    def __init__(self, parent, on_binding_click=None, on_binding_clear=None,
111                 on_mouse_coord=None, on_label_moved=None,
112                 on_hover_input=None, on_hover_shape=None,
113                 on_action_remove=None,
114                 label_positions=None,
115                 icon_loader=None):
116        """
117        Args:
118            parent: tkinter parent widget
119            on_binding_click: callback(input_name: str) when a binding is clicked
120            on_binding_clear: callback(input_name: str) to clear an input's bindings
121            on_mouse_coord: callback(img_x: int, img_y: int) with mouse
122                position in source-image pixel space (1920x1292)
123            on_label_moved: callback(input_name: str, img_x: int, img_y: int)
124                when a binding box is dragged to a new position
125            on_hover_input: callback(input_name: str | None) when hovering
126                over a binding box (None when hover leaves)
127            on_hover_shape: callback(input_names: list[str] | None) when
128                hovering over a controller shape (None when hover leaves)
129            on_action_remove: callback(input_name: str, action_name: str) to
130                remove a single action from an input's bindings
131            label_positions: dict mapping input_name -> [img_x, img_y] for
132                custom label positions (loaded from settings)
133        """
134        super().__init__(parent)
135        self._on_binding_click = on_binding_click
136        self._on_binding_clear = on_binding_clear
137        self._on_mouse_coord = on_mouse_coord
138        self._on_label_moved = on_label_moved
139        self._on_hover_input = on_hover_input
140        self._on_hover_shape = on_hover_shape
141        self._on_action_remove = on_action_remove
142        self._icon_loader = icon_loader
143        self._label_icon_refs: list[ImageTk.PhotoImage] = []
144        self._bindings: dict[str, list[str]] = {}
145
146        # Custom label positions: input_name -> (img_px_x, img_px_y)
147        self._custom_label_pos: dict[str, tuple[int, int]] = {}
148        if label_positions:
149            for name, pos in label_positions.items():
150                if isinstance(pos, (list, tuple)) and len(pos) == 2:
151                    self._custom_label_pos[name] = (int(pos[0]), int(pos[1]))
152
153        # DPI-aware font scaling: tkinter font sizes are in points (1/72 inch).
154        # At 96 DPI (Windows), 9pt = 12 physical pixels.
155        # At 72 DPI (macOS), 9pt = 9 physical pixels — 25% smaller.
156        # Correct by scaling fonts up to match the Windows 96 DPI baseline.
157        try:
158            actual_dpi = self.winfo_fpixels('1i')
159            self._dpi_scale = max(96.0 / actual_dpi, 1.0)
160        except Exception:
161            self._dpi_scale = 1.0
162
163        # Initialize scaled sizes at reference scale (updated each redraw)
164        self._compute_scaled_sizes(1.0)
165
166        # Canvas item tracking
167        self._box_items: dict[str, list[int]] = {}      # input_name -> item ids
168        self._line_items: dict[str, int] = {}            # input_name -> line item id
169        self._shape_items: dict[str, int] = {}           # shape.name -> canvas item id
170        self._connector_group_items: dict[str, tuple[int, int | None]] = {}
171        self._shape_map: dict[str, ButtonShape] = {}     # shape.name -> ButtonShape
172
173        self._hover_input: str | None = None    # hovered binding box
174        self._hover_shape: str | None = None    # hovered controller shape
175        self._selected_input: str | None = None  # selected (red line) input
176        self._show_borders: bool = False
177        self._labels_locked: bool = False        # prevent label dragging
178        self._hide_unassigned: bool = False      # hide inputs with no bindings
179        self._dragging_from_panel: bool = False  # cross-widget drag active
180
181        # Drag state
182        self._dragging: str | None = None
183        self._drag_start: tuple[float, float] = (0, 0)
184        self._did_drag: bool = False
185
186        # Drop target highlight (for drag-and-drop from action panel)
187        self._drop_highlight_id: int | None = None
188        self._dim_overlay_ids: list[int] = []   # grey overlays on incompatible inputs
189
190        # Tooltip
191        self._tooltip: tk.Toplevel | None = None
192
193        # Per-label rumble icon PhotoImages (prevent GC)
194        self._rumble_label_icons: list[ImageTk.PhotoImage] = []
195
196        self._canvas = tk.Canvas(self, bg="white", highlightthickness=0)
197        self._canvas.pack(fill=tk.BOTH, expand=True)
198
199        self._load_image()
200        self._canvas.bind("<Configure>", self._on_resize)
201        self._canvas.bind("<Motion>", self._on_mouse_move)
202        self._canvas.bind("<Button-1>", self._on_press)
203        self._canvas.bind("<B1-Motion>", self._on_drag)
204        self._canvas.bind("<ButtonRelease-1>", self._on_release)
205        self._canvas.bind("<Button-3>", self._on_right_click)
206        self._canvas.bind("<Leave>", self._on_leave)

Args: parent: tkinter parent widget on_binding_click: callback(input_name: str) when a binding is clicked on_binding_clear: callback(input_name: str) to clear an input's bindings on_mouse_coord: callback(img_x: int, img_y: int) with mouse position in source-image pixel space (1920x1292) on_label_moved: callback(input_name: str, img_x: int, img_y: int) when a binding box is dragged to a new position on_hover_input: callback(input_name: str | None) when hovering over a binding box (None when hover leaves) on_hover_shape: callback(input_names: list[str] | None) when hovering over a controller shape (None when hover leaves) on_action_remove: callback(input_name: str, action_name: str) to remove a single action from an input's bindings label_positions: dict mapping input_name -> [img_x, img_y] for custom label positions (loaded from settings)

def set_bindings(self, bindings: dict[str, list[str]]):
283    def set_bindings(self, bindings: dict[str, list[str]]):
284        """Update the displayed bindings and redraw."""
285        self._bindings = dict(bindings)
286        self._redraw()

Update the displayed bindings and redraw.

def set_show_borders(self, show: bool):
288    def set_show_borders(self, show: bool):
289        """Toggle visibility of shape outlines and redraw."""
290        self._show_borders = show
291        self._redraw()

Toggle visibility of shape outlines and redraw.

def set_hide_unassigned(self, hide: bool):
293    def set_hide_unassigned(self, hide: bool):
294        """Toggle hiding of inputs with no bindings and redraw."""
295        self._hide_unassigned = hide
296        self._redraw()

Toggle hiding of inputs with no bindings and redraw.

def set_drag_cursor(self, dragging: bool):
298    def set_drag_cursor(self, dragging: bool):
299        """Set cross-widget drag state so hover cursor is overridden."""
300        self._dragging_from_panel = dragging
301        self._canvas.config(cursor="plus" if dragging else "")

Set cross-widget drag state so hover cursor is overridden.

def reset_label_positions(self):
303    def reset_label_positions(self):
304        """Clear all custom label positions and redraw at defaults."""
305        self._custom_label_pos.clear()
306        self._redraw()

Clear all custom label positions and redraw at defaults.

def set_labels_locked(self, locked: bool):
308    def set_labels_locked(self, locked: bool):
309        """Lock or unlock label dragging."""
310        self._labels_locked = locked

Lock or unlock label dragging.

def highlight_drop_target(self, x_root: int, y_root: int) -> str | None:
318    def highlight_drop_target(self, x_root: int, y_root: int) -> str | None:
319        """Highlight the binding box or shape under root coords.
320
321        Returns the input name if over a single-input target, None otherwise.
322        """
323        self.clear_drop_highlight()
324        cx, cy = self._root_to_canvas(x_root, y_root)
325
326        # Check binding boxes first
327        box_hit = self._hit_test_box(cx, cy)
328        if box_hit and box_hit in self._box_items:
329            box_id = self._box_items[box_hit][0]
330            coords = self._canvas.coords(box_id)
331            if len(coords) == 4:
332                self._drop_highlight_id = self._canvas.create_rectangle(
333                    coords[0] - 2, coords[1] - 2,
334                    coords[2] + 2, coords[3] + 2,
335                    outline="#2266cc", width=3, fill="",
336                )
337            return box_hit
338
339        # Check shapes
340        shape_hit = self._hit_test_shape(cx, cy)
341        if shape_hit and shape_hit.name in self._shape_items:
342            item_id = self._shape_items[shape_hit.name]
343            coords = self._canvas.coords(item_id)
344            if len(coords) == 4:
345                self._drop_highlight_id = self._canvas.create_rectangle(
346                    coords[0] - 2, coords[1] - 2,
347                    coords[2] + 2, coords[3] + 2,
348                    outline="#2266cc", width=3, fill="",
349                )
350            if len(shape_hit.inputs) == 1:
351                return shape_hit.inputs[0]
352            # Multi-input shape: caller should handle via get_drop_target
353            return None
354
355        return None

Highlight the binding box or shape under root coords.

Returns the input name if over a single-input target, None otherwise.

def get_drop_target(self, x_root: int, y_root: int):
357    def get_drop_target(self, x_root: int, y_root: int):
358        """Return (input_name, shape) at root coordinates for drop resolution.
359
360        Returns:
361            (str, None) if over a binding box (direct single-input target)
362            (str, None) if over a single-input shape
363            (None, ButtonShape) if over a multi-input shape (caller shows menu)
364            (None, None) if not over anything
365        """
366        cx, cy = self._root_to_canvas(x_root, y_root)
367
368        box_hit = self._hit_test_box(cx, cy)
369        if box_hit:
370            return box_hit, None
371
372        shape_hit = self._hit_test_shape(cx, cy)
373        if shape_hit:
374            if len(shape_hit.inputs) == 1:
375                return shape_hit.inputs[0], None
376            return None, shape_hit
377
378        return None, None

Return (input_name, shape) at root coordinates for drop resolution.

Returns: (str, None) if over a binding box (direct single-input target) (str, None) if over a single-input shape (None, ButtonShape) if over a multi-input shape (caller shows menu) (None, None) if not over anything

def clear_drop_highlight(self):
380    def clear_drop_highlight(self):
381        """Remove any drop target highlighting."""
382        if self._drop_highlight_id is not None:
383            self._canvas.delete(self._drop_highlight_id)
384            self._drop_highlight_id = None

Remove any drop target highlighting.

def dim_incompatible_inputs(self, compatible_names: set[str]):
386    def dim_incompatible_inputs(self, compatible_names: set[str]):
387        """Grey out incompatible boxes and highlight compatible ones.
388
389        Also shows green outlines on controller button shapes that
390        contain at least one compatible input.
391        """
392        self.clear_dim_overlays()
393        # Highlight/dim binding boxes
394        for name, item_ids in self._box_items.items():
395            if not item_ids:
396                continue
397            box_id = item_ids[0]
398            coords = self._canvas.coords(box_id)
399            if len(coords) != 4:
400                continue
401            if name in compatible_names:
402                # Green highlight border around compatible inputs
403                oid = self._canvas.create_rectangle(
404                    coords[0] - 3, coords[1] - 3,
405                    coords[2] + 3, coords[3] + 3,
406                    outline="#33aa33", width=3, fill="",
407                )
408                self._dim_overlay_ids.append(oid)
409            else:
410                # Solid grey overlay on incompatible inputs
411                oid = self._canvas.create_rectangle(
412                    coords[0], coords[1], coords[2], coords[3],
413                    fill="#bbbbbb", outline="#999999", stipple="gray75",
414                )
415                self._dim_overlay_ids.append(oid)
416
417        # Green outlines on compatible button shapes
418        for shape_name, shape in self._shape_map.items():
419            has_compatible = any(
420                inp in compatible_names for inp in shape.inputs)
421            if not has_compatible:
422                continue
423            item_id = self._shape_items.get(shape_name)
424            if item_id is None:
425                continue
426            coords = self._canvas.coords(item_id)
427            if len(coords) != 4:
428                continue
429            cx = (coords[0] + coords[2]) / 2
430            cy = (coords[1] + coords[3]) / 2
431            hw = (coords[2] - coords[0]) / 2
432            hh = (coords[3] - coords[1]) / 2
433            if shape.shape in ("circle", "pill"):
434                oid = self._canvas.create_oval(
435                    cx - hw, cy - hh, cx + hw, cy + hh,
436                    outline="#33aa33", width=3, fill="",
437                )
438            else:
439                oid = self._canvas.create_rectangle(
440                    cx - hw, cy - hh, cx + hw, cy + hh,
441                    outline="#33aa33", width=3, fill="",
442                )
443            self._dim_overlay_ids.append(oid)

Grey out incompatible boxes and highlight compatible ones.

Also shows green outlines on controller button shapes that contain at least one compatible input.

def clear_dim_overlays(self):
445    def clear_dim_overlays(self):
446        """Remove drag compatibility overlays."""
447        for oid in self._dim_overlay_ids:
448            self._canvas.delete(oid)
449        self._dim_overlay_ids.clear()

Remove drag compatibility overlays.

def clear_selection(self):
957    def clear_selection(self):
958        """Clear the selected input, restoring line to default color."""
959        self._select_input(None)

Clear the selected input, restoring line to default color.