Solaar/lib/solaar/ui/perkey/_icons.py

134 lines
5.1 KiB
Python

## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Theme-aware loader for Solaar's per-key UI icons.
Loads SVG icons from ``share/solaar/icons/`` and recolors them at load
time to match the active GTK theme's text foreground, by substituting
``currentColor`` in the SVG before passing it to GdkPixbuf. GTK's stock
symbolic loader is bypassed because it only recolors specific palette
fill stand-ins and ignores ``stroke="currentColor"``.
"""
from __future__ import annotations
import logging
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf # NOQA: E402
from gi.repository import Gio # NOQA: E402
from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
ICON_PIXEL_SIZE = 22
_search_path_added = False
def ensure_icon_path() -> None:
"""Register share/solaar/icons with the default GtkIconTheme so our
custom symbolic tool icons resolve by name. Idempotent."""
global _search_path_added
if _search_path_added:
return
theme = Gtk.IconTheme.get_default()
existing = set(theme.get_search_path() or [])
# _icons.py: lib/solaar/ui/perkey/_icons.py -> parents[4] = repo root
candidates = [
Path(__file__).resolve().parents[4] / "share" / "solaar" / "icons",
]
for c in candidates:
if c.is_dir() and str(c) not in existing:
theme.append_search_path(str(c))
_search_path_added = True
def themed_icon_image(icon_name: str, style_widget: Gtk.Widget) -> Gtk.Image | None:
"""Load a Solaar tool icon and recolor it to match the given widget's
text foreground color, so the icons follow the active GTK theme
(light / dark / custom). Returns None if the icon can't be loaded.
"""
ensure_icon_path()
theme = Gtk.IconTheme.get_default()
icon_info = theme.lookup_icon(icon_name, ICON_PIXEL_SIZE, Gtk.IconLookupFlags.FORCE_SIZE)
if icon_info is None:
return None
path = icon_info.get_filename()
if not path:
return None
fg = style_widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
color = f"#{int(fg.red * 255):02x}{int(fg.green * 255):02x}{int(fg.blue * 255):02x}"
try:
with open(path, "r", encoding="utf-8") as f:
svg = f.read()
svg = svg.replace("currentColor", color)
stream = Gio.MemoryInputStream.new_from_data(svg.encode("utf-8"))
pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(stream, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE, True)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception as e:
logger.debug("recolor failed for %s: %s", icon_name, e)
return None
def _fg_color_key(widget: Gtk.Widget) -> tuple[float, float, float]:
fg = widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
return (round(fg.red, 3), round(fg.green, 3), round(fg.blue, 3))
def attach_themed_icon(button: Gtk.Container, icon_name: str) -> int | None:
"""Add a themed icon to `button` and re-render it whenever the active
GTK theme changes the button's foreground color. Returns the
style-updated signal handler ID, or None if the icon couldn't be
loaded (in which case the button is left unchanged so the caller can
fall back to a text label).
Listening to the button's own ``style-updated`` signal — instead of
``Gtk.Settings notify::gtk-theme-name`` — means we read the
foreground color *after* GTK has re-resolved CSS for the new theme.
Subscribing to the Settings notify fires too early; it returns the
stale (pre-switch) color and produces icons that all settle on the
previous theme's tone. We guard the rebuild with a per-button color
key so unrelated style updates (hover, focus, active) don't trigger
needless re-renders.
"""
image = themed_icon_image(icon_name, button)
if image is None:
return None
button.add(image)
image.show()
state = {"color_key": _fg_color_key(button)}
def _refresh(_widget) -> None:
new_key = _fg_color_key(button)
if new_key == state["color_key"]:
return
state["color_key"] = new_key
new_image = themed_icon_image(icon_name, button)
if new_image is None:
return
old = button.get_child()
if old is not None:
button.remove(old)
button.add(new_image)
new_image.show()
return button.connect("style-updated", _refresh)