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