diff --git a/lib/solaar/ui/perkey/_icons.py b/lib/solaar/ui/perkey/_icons.py new file mode 100644 index 00000000..d5bd3f47 --- /dev/null +++ b/lib/solaar/ui/perkey/_icons.py @@ -0,0 +1,88 @@ +## 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 diff --git a/lib/solaar/ui/perkey/editor.py b/lib/solaar/ui/perkey/editor.py index 892446fc..7b6b1851 100644 --- a/lib/solaar/ui/perkey/editor.py +++ b/lib/solaar/ui/perkey/editor.py @@ -25,18 +25,16 @@ from __future__ import annotations import logging from enum import Enum -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 from solaar.i18n import _ # NOQA: E402 from . import binding # NOQA: E402 +from ._icons import themed_icon_image # NOQA: E402 from .canvas import KeyboardCanvas # NOQA: E402 from .layout import Layout # NOQA: E402 from .palette import GradientSwatch # NOQA: E402 @@ -65,59 +63,6 @@ _TOOL_ICON_NAMES = { "rect": "solaar-tool-rect-symbolic", "bucket": "solaar-tool-bucket-symbolic", } -_TOOL_ICON_PIXEL_SIZE = 22 - -_icon_search_path_added = False - - -def _ensure_tool_icon_path() -> None: - """Register share/solaar/icons with the default GtkIconTheme so our - custom symbolic tool icons resolve by name. Idempotent.""" - global _icon_search_path_added - if _icon_search_path_added: - return - theme = Gtk.IconTheme.get_default() - existing = set(theme.get_search_path() or []) - # Source-tree path: lib/solaar/ui/perkey/editor.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)) - _icon_search_path_added = True - - -def _tool_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. - - GTK's stock symbolic loader (`load_symbolic_for_context`) only recolors - specific palette stand-ins (e.g. fill="#bebebe"); it ignores - `stroke="currentColor"`. We bypass it and substitute currentColor - ourselves so any SVG using the currentColor convention works. - """ - _ensure_tool_icon_path() - theme = Gtk.IconTheme.get_default() - icon_info = theme.lookup_icon(icon_name, _TOOL_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, _TOOL_ICON_PIXEL_SIZE, _TOOL_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 class PerKeyEditor(Gtk.Box): @@ -148,7 +93,7 @@ class PerKeyEditor(Gtk.Box): icon_name = _TOOL_ICON_NAMES.get(name) btn = Gtk.RadioButton.new_from_widget(first) btn.set_mode(False) # render as toggle button rather than radio - image = _tool_icon_image(icon_name, btn) if icon_name else None + image = themed_icon_image(icon_name, btn) if icon_name else None if image is not None: btn.add(image) btn.set_tooltip_text(tip or label) @@ -209,7 +154,6 @@ class PerKeyEditor(Gtk.Box): logger.debug("zone_base_color read failed: %s", e) base = None self._canvas.set_zone_base_color(base) - self._palette.set_zone_base_color(base) self._refresh_layout() self._sync_from_sink() self._unsubscribe = sink.subscribe(self._on_sink_update) @@ -227,12 +171,16 @@ class PerKeyEditor(Gtk.Box): except Exception as e: logger.debug("theme signal disconnect failed: %s", e) self._theme_signal_handlers = [] + try: + self._palette.shutdown() + except Exception as e: + logger.debug("palette shutdown failed: %s", e) def _on_gtk_theme_changed(self, _settings, _pspec) -> None: """Rebuild themed tool icons so they match the new theme's foreground.""" for btn, icon_name in self._themed_icon_buttons.items(): old = btn.get_child() - new_image = _tool_icon_image(icon_name, btn) + new_image = themed_icon_image(icon_name, btn) if new_image is None: continue if old is not None: diff --git a/lib/solaar/ui/perkey/palette.py b/lib/solaar/ui/perkey/palette.py index 1d738e61..3fc76f51 100644 --- a/lib/solaar/ui/perkey/palette.py +++ b/lib/solaar/ui/perkey/palette.py @@ -24,6 +24,8 @@ itself — see `GradientSwatch` below, used by `editor.py`. from __future__ import annotations +import logging + from enum import Enum import gi @@ -35,6 +37,12 @@ from gi.repository import Gtk # NOQA: E402 from solaar.i18n import _ # NOQA: E402 +from ._icons import themed_icon_image # NOQA: E402 + +logger = logging.getLogger(__name__) + +_UNSET_ICON_NAME = "solaar-tool-palette-off-symbolic" + class GtkSignal(Enum): DRAW = "draw" @@ -62,70 +70,6 @@ def _int_to_rgba(c: int) -> Gdk.RGBA: return rgba -def _draw_hash(cr, x: float, y: float, size: float, base_color: int | None = None) -> None: - """Diagonal hash pattern used as the visual for "no change" / unset. - - Background is the zone base color (the color these cells actually display - on the keyboard) when known; stripes pick a black or white contrast based - on luminance so the texture stays readable on any base. - """ - cr.save() - cr.rectangle(x, y, size, size) - cr.clip() - if base_color is not None and base_color >= 0: - r = ((base_color >> 16) & 0xFF) / 255.0 - g = ((base_color >> 8) & 0xFF) / 255.0 - b = (base_color & 0xFF) / 255.0 - cr.set_source_rgba(r, g, b, 1.0) - else: - r = g = 0.30 - b = 0.32 - cr.set_source_rgba(r, g, b, 1.0) - cr.rectangle(x, y, size, size) - cr.fill() - if base_color is not None and base_color >= 0: - lum = 0.299 * r + 0.587 * g + 0.114 * b - cr.set_source_rgba(0, 0, 0, 0.45) if lum > 0.55 else cr.set_source_rgba(1, 1, 1, 0.35) - else: - cr.set_source_rgba(0.55, 0.55, 0.60, 1.0) - cr.set_line_width(1.2) - step = 4 - d = -int(size) - while d <= int(size): - cr.move_to(x + d, y + size) - cr.line_to(x + d + size, y) - cr.stroke() - d += step - cr.restore() - - -class HashSwatch(Gtk.DrawingArea): - """Square showing the diagonal hash pattern; used as the visual on the - "unset" toggle button and matches how unset cells render on the canvas. - Set the zone base color via `set_base_color` so the swatch reflects what - "no change" cells actually display on the keyboard. - """ - - SIZE = 22 - - def __init__(self) -> None: - super().__init__() - self._base_color: int | None = None - self.set_size_request(self.SIZE, self.SIZE) - self.connect(GtkSignal.DRAW.value, self._on_draw) - - def set_base_color(self, color: int | None) -> None: - self._base_color = None if color is None else int(color) - self.queue_draw() - - def _on_draw(self, _w, cr) -> None: - _draw_hash(cr, 0, 0, float(self.SIZE), self._base_color) - cr.set_source_rgba(0, 0, 0, 0.45) - cr.set_line_width(1.0) - cr.rectangle(0.5, 0.5, self.SIZE - 1, self.SIZE - 1) - cr.stroke() - - # Sentinel for "no change" / unset paint. Matches special_keys.COLORSPLUS["No change"]. UNSET_COLOR = -1 @@ -151,15 +95,44 @@ class Palette(Gtk.Box): self._color_btn.connect(GtkSignal.COLOR_SET.value, self._on_color_set) self.pack_start(self._color_btn, False, False, 0) - self._unset_swatch = HashSwatch() self._unset_btn = Gtk.ToggleButton() self._unset_btn.set_tooltip_text(_("Paint as 'no change' — clears the cell to the zone base color")) - self._unset_btn.add(self._unset_swatch) + self._unset_label = _("Unset") + self._unset_image = themed_icon_image(_UNSET_ICON_NAME, self._unset_btn) + if self._unset_image is not None: + self._unset_btn.add(self._unset_image) + self._unset_btn.get_accessible().set_name(self._unset_label) + else: + self._unset_btn.set_label(self._unset_label) self._unset_btn.connect(GtkSignal.TOGGLED.value, self._on_unset_toggled) self.pack_start(self._unset_btn, False, False, 0) - def set_zone_base_color(self, color: int | None) -> None: - self._unset_swatch.set_base_color(color) + # Track theme changes so the unset icon re-renders to match. + self._theme_signal_handlers: list[tuple[object, int]] = [] + if self._unset_image is not None: + settings = Gtk.Settings.get_default() + for prop in ("notify::gtk-theme-name", "notify::gtk-application-prefer-dark-theme"): + hid = settings.connect(prop, self._on_gtk_theme_changed) + self._theme_signal_handlers.append((settings, hid)) + + def shutdown(self) -> None: + for obj, hid in self._theme_signal_handlers: + try: + obj.disconnect(hid) + except Exception as e: + logger.debug("palette theme signal disconnect failed: %s", e) + self._theme_signal_handlers = [] + + def _on_gtk_theme_changed(self, _settings, _pspec) -> None: + new_image = themed_icon_image(_UNSET_ICON_NAME, self._unset_btn) + if new_image is None: + return + old = self._unset_btn.get_child() + if old is not None: + self._unset_btn.remove(old) + self._unset_btn.add(new_image) + new_image.show() + self._unset_image = new_image def _on_color_set(self, btn: Gtk.ColorButton) -> None: c = _rgb_to_int(btn.get_rgba()) diff --git a/share/solaar/icons/THIRD_PARTY.md b/share/solaar/icons/THIRD_PARTY.md index 9326f212..86eb4d59 100644 --- a/share/solaar/icons/THIRD_PARTY.md +++ b/share/solaar/icons/THIRD_PARTY.md @@ -13,6 +13,7 @@ copyright (c) 2020-2024 Paweł Kuna, distributed under the MIT License: - `solaar-tool-brush-symbolic.svg` — from `brush` - `solaar-tool-rect-symbolic.svg` — from `paint` - `solaar-tool-bucket-symbolic.svg` — from `bucket-droplet` +- `solaar-tool-palette-off-symbolic.svg` — from `palette-off` ### MIT License (Tabler Icons) diff --git a/share/solaar/icons/solaar-tool-palette-off-symbolic.svg b/share/solaar/icons/solaar-tool-palette-off-symbolic.svg new file mode 100644 index 00000000..2a448ff2 --- /dev/null +++ b/share/solaar/icons/solaar-tool-palette-off-symbolic.svg @@ -0,0 +1,24 @@ + + + + + + + + +