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.
This commit is contained in:
Ken Sanislo 2026-05-11 00:44:46 -07:00 committed by Peter F. Patel-Schneider
parent 1210d32b93
commit 661a186429
5 changed files with 161 additions and 127 deletions

View File

@ -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

View File

@ -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:

View File

@ -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())

View File

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

View File

@ -0,0 +1,24 @@
<!--
SPDX-License-Identifier: MIT
SPDX-FileCopyrightText: 2020-2024 Paweł Kuna
Source: Tabler Icons (https://tabler.io/icons) - "palette-off"
License: MIT (https://github.com/tabler/tabler-icons/blob/main/LICENSE)
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 15h-1a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25a9 9 0 0 1 -6.372 -15.356" />
<path d="M8 4c1.236 -.623 2.569 -1 4 -1c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828a4.516 4.516 0 0 1 -1.127 .73" />
<path d="M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M3 3l18 18" />
</svg>

After

Width:  |  Height:  |  Size: 832 B