host.controller_config.icon_loader

Centralized Xbox controller icon loading, theming, and caching.

Loads pre-split 256px transparent PNGs from the XboxControlIcons pack. Selects Black or White variant based on system color scheme, adds colored backgrounds for A/B/X/Y face buttons, and composites diagonal D-pad icons.

Xbox Series Button Icons and Controls by Zacksly Licensed under CC BY 3.0 - https://zacksly.itch.io

  1"""Centralized Xbox controller icon loading, theming, and caching.
  2
  3Loads pre-split 256px transparent PNGs from the XboxControlIcons pack.
  4Selects Black or White variant based on system color scheme, adds colored
  5backgrounds for A/B/X/Y face buttons, and composites diagonal D-pad icons.
  6
  7Xbox Series Button Icons and Controls by Zacksly
  8Licensed under CC BY 3.0 - https://zacksly.itch.io
  9"""
 10
 11from pathlib import Path
 12from typing import Optional
 13
 14from PIL import Image, ImageDraw, ImageTk
 15
 16# Input name -> icon filename(s) in Buttons Full Solid/{Black|White}/256w/
 17# Tuple values indicate compositing (layer both images together).
 18INPUT_ICON_MAP: dict[str, str | tuple[str, str]] = {
 19    # Face buttons (get colored backgrounds)
 20    "a_button": "A.png",
 21    "b_button": "B.png",
 22    "x_button": "X.png",
 23    "y_button": "Y.png",
 24    # Bumpers & triggers
 25    "left_bumper": "Left Bumper.png",
 26    "right_bumper": "Right Bumper.png",
 27    "left_trigger": "Left Trigger.png",
 28    "right_trigger": "Right Trigger.png",
 29    # Stick buttons
 30    "left_stick_button": "Left Stick Click.png",
 31    "right_stick_button": "Right Stick Click.png",
 32    # Stick axes
 33    "left_stick_x": "Left Stick Left-Right.png",
 34    "left_stick_y": "Left Stick Up-Down.png",
 35    "right_stick_x": "Right Stick Left-Right.png",
 36    "right_stick_y": "Right Stick Up-Down.png",
 37    # D-pad cardinal directions
 38    "pov_up": "D-Pad Up.png",
 39    "pov_down": "D-Pad Down.png",
 40    "pov_left": "D-Pad Left.png",
 41    "pov_right": "D-Pad Right.png",
 42    # D-pad diagonals (composite two cardinal icons)
 43    "pov_up_right": ("D-Pad Up.png", "D-Pad Right.png"),
 44    "pov_down_right": ("D-Pad Down.png", "D-Pad Right.png"),
 45    "pov_down_left": ("D-Pad Down.png", "D-Pad Left.png"),
 46    "pov_up_left": ("D-Pad Up.png", "D-Pad Left.png"),
 47    # Special buttons
 48    "back_button": "View.png",
 49    "start_button": "Menu.png",
 50    # rumble_* uses existing rumble.svg — not in this icon pack
 51}
 52
 53# Xbox brand colors for face button backgrounds
 54FACE_BUTTON_COLORS = {
 55    "a_button": "#107C10",
 56    "b_button": "#B7191C",
 57    "x_button": "#0078D7",
 58    "y_button": "#FFB900",
 59}
 60
 61
 62class InputIconLoader:
 63    """Loads and caches Xbox controller input icons.
 64
 65    Selects Black or White icon variant based on the system color scheme.
 66    Face buttons (A/B/X/Y) get colored circle backgrounds.
 67    Diagonal D-pad icons are composited from two cardinal directions.
 68    """
 69
 70    def __init__(self, icons_base_dir: Path, root=None):
 71        """Initialize the icon loader.
 72
 73        Args:
 74            icons_base_dir: Path to ``Buttons Full Solid/`` directory
 75                containing ``Black/256w/`` and ``White/256w/`` subdirs.
 76            root: Optional tkinter root widget for theme detection.
 77                If ``None``, defaults to Black icons.
 78        """
 79        self._base_dir = icons_base_dir
 80        self._root = root
 81        self._use_white = False
 82        self._pil_cache: dict[tuple[str, int], Image.Image] = {}
 83        self._tk_cache: dict[tuple[str, int], ImageTk.PhotoImage] = {}
 84        if root is not None:
 85            self._detect_theme()
 86
 87    # ------------------------------------------------------------------
 88    # Public API
 89    # ------------------------------------------------------------------
 90
 91    def get_tk_icon(
 92        self, input_name: str, size: int = 16
 93    ) -> Optional[ImageTk.PhotoImage]:
 94        """Return a cached tkinter PhotoImage for the given input and size."""
 95        key = (input_name, size)
 96        if key in self._tk_cache:
 97            return self._tk_cache[key]
 98        pil_img = self.get_pil_icon(input_name, size)
 99        if pil_img is None:
100            return None
101        tk_img = ImageTk.PhotoImage(pil_img)
102        self._tk_cache[key] = tk_img
103        return tk_img
104
105    def get_pil_icon(
106        self, input_name: str, size: int = 16
107    ) -> Optional[Image.Image]:
108        """Return a cached PIL RGBA image for the given input and size."""
109        key = (input_name, size)
110        if key in self._pil_cache:
111            return self._pil_cache[key]
112        img = self._build_icon(input_name, size)
113        if img is not None:
114            self._pil_cache[key] = img
115        return img
116
117    def refresh_theme(self):
118        """Re-detect system theme and clear all caches."""
119        if self._root is not None:
120            self._detect_theme()
121        self._pil_cache.clear()
122        self._tk_cache.clear()
123
124    # ------------------------------------------------------------------
125    # Internal
126    # ------------------------------------------------------------------
127
128    def _detect_theme(self):
129        """Detect light/dark mode from the default background color."""
130        try:
131            bg = self._root.cget("background")
132            r, g, b = self._root.winfo_rgb(bg)
133            # winfo_rgb returns 16-bit values (0-65535)
134            luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 65535
135            self._use_white = luminance < 0.5
136        except Exception:
137            self._use_white = False
138
139    def _icon_dir(self, variant: str = "") -> Path:
140        """Return the 256w directory for the given variant."""
141        if not variant:
142            variant = "White" if self._use_white else "Black"
143        return self._base_dir / variant / "256w"
144
145    def _load_raw(self, filename: str, variant: str = "") -> Optional[Image.Image]:
146        """Load a single icon PNG as RGBA."""
147        path = self._icon_dir(variant) / filename
148        if not path.exists():
149            return None
150        return Image.open(path).convert("RGBA")
151
152    def _build_icon(self, input_name: str, size: int) -> Optional[Image.Image]:
153        """Build the final icon image for an input at the requested size."""
154        mapping = INPUT_ICON_MAP.get(input_name)
155        if mapping is None:
156            return None
157
158        # Face buttons: colored background + white icon on top
159        if input_name in FACE_BUTTON_COLORS:
160            img = self._build_face_button(input_name, mapping)
161        elif isinstance(mapping, tuple):
162            # Diagonal D-pad: composite two cardinal directions
163            img = self._build_composite(mapping)
164        else:
165            img = self._load_raw(mapping)
166
167        if img is None:
168            return None
169
170        # Resize with high-quality resampling
171        img = img.resize((size, size), Image.LANCZOS)
172        return img
173
174    def _build_face_button(
175        self, input_name: str, filename: str
176    ) -> Optional[Image.Image]:
177        """Build a face button icon with colored circle background."""
178        # Always use White variant for face buttons (white letter on color)
179        icon = self._load_raw(filename, variant="White")
180        if icon is None:
181            return None
182
183        w, h = icon.size
184        color_hex = FACE_BUTTON_COLORS[input_name]
185        r = int(color_hex[1:3], 16)
186        g = int(color_hex[3:5], 16)
187        b = int(color_hex[5:7], 16)
188
189        # Create colored circle background matching icon size
190        bg = Image.new("RGBA", (w, h), (0, 0, 0, 0))
191        draw = ImageDraw.Draw(bg)
192        # The icon is a circle centered in the image with some padding
193        # Draw a filled circle slightly larger than the icon's circle
194        padding = int(w * 0.12)
195        draw.ellipse(
196            [padding, padding, w - padding, h - padding],
197            fill=(r, g, b, 255),
198        )
199
200        # Composite white icon on top of colored circle
201        result = Image.alpha_composite(bg, icon)
202        return result
203
204    def _build_composite(
205        self, filenames: tuple[str, str]
206    ) -> Optional[Image.Image]:
207        """Composite two icon images together (for diagonal D-pad)."""
208        img1 = self._load_raw(filenames[0])
209        img2 = self._load_raw(filenames[1])
210        if img1 is None or img2 is None:
211            return img1 or img2
212        return Image.alpha_composite(img1, img2)
INPUT_ICON_MAP: dict[str, str | tuple[str, str]] = {'a_button': 'A.png', 'b_button': 'B.png', 'x_button': 'X.png', 'y_button': 'Y.png', 'left_bumper': 'Left Bumper.png', 'right_bumper': 'Right Bumper.png', 'left_trigger': 'Left Trigger.png', 'right_trigger': 'Right Trigger.png', 'left_stick_button': 'Left Stick Click.png', 'right_stick_button': 'Right Stick Click.png', 'left_stick_x': 'Left Stick Left-Right.png', 'left_stick_y': 'Left Stick Up-Down.png', 'right_stick_x': 'Right Stick Left-Right.png', 'right_stick_y': 'Right Stick Up-Down.png', 'pov_up': 'D-Pad Up.png', 'pov_down': 'D-Pad Down.png', 'pov_left': 'D-Pad Left.png', 'pov_right': 'D-Pad Right.png', 'pov_up_right': ('D-Pad Up.png', 'D-Pad Right.png'), 'pov_down_right': ('D-Pad Down.png', 'D-Pad Right.png'), 'pov_down_left': ('D-Pad Down.png', 'D-Pad Left.png'), 'pov_up_left': ('D-Pad Up.png', 'D-Pad Left.png'), 'back_button': 'View.png', 'start_button': 'Menu.png'}
FACE_BUTTON_COLORS = {'a_button': '#107C10', 'b_button': '#B7191C', 'x_button': '#0078D7', 'y_button': '#FFB900'}
class InputIconLoader:
 63class InputIconLoader:
 64    """Loads and caches Xbox controller input icons.
 65
 66    Selects Black or White icon variant based on the system color scheme.
 67    Face buttons (A/B/X/Y) get colored circle backgrounds.
 68    Diagonal D-pad icons are composited from two cardinal directions.
 69    """
 70
 71    def __init__(self, icons_base_dir: Path, root=None):
 72        """Initialize the icon loader.
 73
 74        Args:
 75            icons_base_dir: Path to ``Buttons Full Solid/`` directory
 76                containing ``Black/256w/`` and ``White/256w/`` subdirs.
 77            root: Optional tkinter root widget for theme detection.
 78                If ``None``, defaults to Black icons.
 79        """
 80        self._base_dir = icons_base_dir
 81        self._root = root
 82        self._use_white = False
 83        self._pil_cache: dict[tuple[str, int], Image.Image] = {}
 84        self._tk_cache: dict[tuple[str, int], ImageTk.PhotoImage] = {}
 85        if root is not None:
 86            self._detect_theme()
 87
 88    # ------------------------------------------------------------------
 89    # Public API
 90    # ------------------------------------------------------------------
 91
 92    def get_tk_icon(
 93        self, input_name: str, size: int = 16
 94    ) -> Optional[ImageTk.PhotoImage]:
 95        """Return a cached tkinter PhotoImage for the given input and size."""
 96        key = (input_name, size)
 97        if key in self._tk_cache:
 98            return self._tk_cache[key]
 99        pil_img = self.get_pil_icon(input_name, size)
100        if pil_img is None:
101            return None
102        tk_img = ImageTk.PhotoImage(pil_img)
103        self._tk_cache[key] = tk_img
104        return tk_img
105
106    def get_pil_icon(
107        self, input_name: str, size: int = 16
108    ) -> Optional[Image.Image]:
109        """Return a cached PIL RGBA image for the given input and size."""
110        key = (input_name, size)
111        if key in self._pil_cache:
112            return self._pil_cache[key]
113        img = self._build_icon(input_name, size)
114        if img is not None:
115            self._pil_cache[key] = img
116        return img
117
118    def refresh_theme(self):
119        """Re-detect system theme and clear all caches."""
120        if self._root is not None:
121            self._detect_theme()
122        self._pil_cache.clear()
123        self._tk_cache.clear()
124
125    # ------------------------------------------------------------------
126    # Internal
127    # ------------------------------------------------------------------
128
129    def _detect_theme(self):
130        """Detect light/dark mode from the default background color."""
131        try:
132            bg = self._root.cget("background")
133            r, g, b = self._root.winfo_rgb(bg)
134            # winfo_rgb returns 16-bit values (0-65535)
135            luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 65535
136            self._use_white = luminance < 0.5
137        except Exception:
138            self._use_white = False
139
140    def _icon_dir(self, variant: str = "") -> Path:
141        """Return the 256w directory for the given variant."""
142        if not variant:
143            variant = "White" if self._use_white else "Black"
144        return self._base_dir / variant / "256w"
145
146    def _load_raw(self, filename: str, variant: str = "") -> Optional[Image.Image]:
147        """Load a single icon PNG as RGBA."""
148        path = self._icon_dir(variant) / filename
149        if not path.exists():
150            return None
151        return Image.open(path).convert("RGBA")
152
153    def _build_icon(self, input_name: str, size: int) -> Optional[Image.Image]:
154        """Build the final icon image for an input at the requested size."""
155        mapping = INPUT_ICON_MAP.get(input_name)
156        if mapping is None:
157            return None
158
159        # Face buttons: colored background + white icon on top
160        if input_name in FACE_BUTTON_COLORS:
161            img = self._build_face_button(input_name, mapping)
162        elif isinstance(mapping, tuple):
163            # Diagonal D-pad: composite two cardinal directions
164            img = self._build_composite(mapping)
165        else:
166            img = self._load_raw(mapping)
167
168        if img is None:
169            return None
170
171        # Resize with high-quality resampling
172        img = img.resize((size, size), Image.LANCZOS)
173        return img
174
175    def _build_face_button(
176        self, input_name: str, filename: str
177    ) -> Optional[Image.Image]:
178        """Build a face button icon with colored circle background."""
179        # Always use White variant for face buttons (white letter on color)
180        icon = self._load_raw(filename, variant="White")
181        if icon is None:
182            return None
183
184        w, h = icon.size
185        color_hex = FACE_BUTTON_COLORS[input_name]
186        r = int(color_hex[1:3], 16)
187        g = int(color_hex[3:5], 16)
188        b = int(color_hex[5:7], 16)
189
190        # Create colored circle background matching icon size
191        bg = Image.new("RGBA", (w, h), (0, 0, 0, 0))
192        draw = ImageDraw.Draw(bg)
193        # The icon is a circle centered in the image with some padding
194        # Draw a filled circle slightly larger than the icon's circle
195        padding = int(w * 0.12)
196        draw.ellipse(
197            [padding, padding, w - padding, h - padding],
198            fill=(r, g, b, 255),
199        )
200
201        # Composite white icon on top of colored circle
202        result = Image.alpha_composite(bg, icon)
203        return result
204
205    def _build_composite(
206        self, filenames: tuple[str, str]
207    ) -> Optional[Image.Image]:
208        """Composite two icon images together (for diagonal D-pad)."""
209        img1 = self._load_raw(filenames[0])
210        img2 = self._load_raw(filenames[1])
211        if img1 is None or img2 is None:
212            return img1 or img2
213        return Image.alpha_composite(img1, img2)

Loads and caches Xbox controller input icons.

Selects Black or White icon variant based on the system color scheme. Face buttons (A/B/X/Y) get colored circle backgrounds. Diagonal D-pad icons are composited from two cardinal directions.

InputIconLoader(icons_base_dir: pathlib.Path, root=None)
71    def __init__(self, icons_base_dir: Path, root=None):
72        """Initialize the icon loader.
73
74        Args:
75            icons_base_dir: Path to ``Buttons Full Solid/`` directory
76                containing ``Black/256w/`` and ``White/256w/`` subdirs.
77            root: Optional tkinter root widget for theme detection.
78                If ``None``, defaults to Black icons.
79        """
80        self._base_dir = icons_base_dir
81        self._root = root
82        self._use_white = False
83        self._pil_cache: dict[tuple[str, int], Image.Image] = {}
84        self._tk_cache: dict[tuple[str, int], ImageTk.PhotoImage] = {}
85        if root is not None:
86            self._detect_theme()

Initialize the icon loader.

Args: icons_base_dir: Path to Buttons Full Solid/ directory containing Black/256w/ and White/256w/ subdirs. root: Optional tkinter root widget for theme detection. If None, defaults to Black icons.

def get_tk_icon(self, input_name: str, size: int = 16) -> PIL.ImageTk.PhotoImage | None:
 92    def get_tk_icon(
 93        self, input_name: str, size: int = 16
 94    ) -> Optional[ImageTk.PhotoImage]:
 95        """Return a cached tkinter PhotoImage for the given input and size."""
 96        key = (input_name, size)
 97        if key in self._tk_cache:
 98            return self._tk_cache[key]
 99        pil_img = self.get_pil_icon(input_name, size)
100        if pil_img is None:
101            return None
102        tk_img = ImageTk.PhotoImage(pil_img)
103        self._tk_cache[key] = tk_img
104        return tk_img

Return a cached tkinter PhotoImage for the given input and size.

def get_pil_icon(self, input_name: str, size: int = 16) -> PIL.Image.Image | None:
106    def get_pil_icon(
107        self, input_name: str, size: int = 16
108    ) -> Optional[Image.Image]:
109        """Return a cached PIL RGBA image for the given input and size."""
110        key = (input_name, size)
111        if key in self._pil_cache:
112            return self._pil_cache[key]
113        img = self._build_icon(input_name, size)
114        if img is not None:
115            self._pil_cache[key] = img
116        return img

Return a cached PIL RGBA image for the given input and size.

def refresh_theme(self):
118    def refresh_theme(self):
119        """Re-detect system theme and clear all caches."""
120        if self._root is not None:
121            self._detect_theme()
122        self._pil_cache.clear()
123        self._tk_cache.clear()

Re-detect system theme and clear all caches.