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)
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.
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)
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.
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.
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.
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.
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.
308 def set_labels_locked(self, locked: bool): 309 """Lock or unlock label dragging.""" 310 self._labels_locked = locked
Lock or unlock label dragging.
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.
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
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.
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.