134 lines
5.1 KiB
Python
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)
|