From 661a1864290db2cf542733f00e915df1df59579e Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Mon, 11 May 2026 00:44:46 -0700 Subject: [PATCH] PerKey palette: replace hashed unset swatch with palette-off icon The "Unset" toggle button next to the color picker used a custom HashSwatch drawing area that mirrored the canvas's diagonal-hash pattern for unset cells. The pattern was visually noisy at button size and tied the button's appearance to the current zone base color (the swatch had to be told the base color via set_zone_base_color so it could redraw). Use the Tabler "palette-off" symbolic icon instead. It reads as "clear / no paint" at a glance, is independent of the zone base color, and matches the icon style of the tool buttons on the other end of the toolbar. Extract the icon loader (themed_icon_image, ensure_icon_path) from editor.py into a new private module _icons.py so palette.py can reuse it; the new palette tracks its own Gtk.Settings notify signals to re-render the icon on theme switches, and disconnects them via a new Palette.shutdown() called from the editor's shutdown. Adds MIT-licensed Tabler palette-off icon under share/solaar/icons/, see THIRD_PARTY.md. --- lib/solaar/ui/perkey/_icons.py | 88 ++++++++++++++ lib/solaar/ui/perkey/editor.py | 66 ++--------- lib/solaar/ui/perkey/palette.py | 109 +++++++----------- share/solaar/icons/THIRD_PARTY.md | 1 + .../solaar-tool-palette-off-symbolic.svg | 24 ++++ 5 files changed, 161 insertions(+), 127 deletions(-) create mode 100644 lib/solaar/ui/perkey/_icons.py create mode 100644 share/solaar/icons/solaar-tool-palette-off-symbolic.svg 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 @@ + + + + + + + + +