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:
parent
1210d32b93
commit
661a186429
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue