PerKey icons: read theme fg from style-updated, not Settings notify

Previously the editor and palette listened to Gtk.Settings
notify::gtk-theme-name (and notify::gtk-application-prefer-dark-theme)
to re-render their themed icons on theme switch. Two problems:

1. The initial icon load happened during widget construction, before
   the buttons were attached to the toolbar — so the style context
   resolved to a default (often white) foreground rather than the
   actual theme text color. Icons showed up white until the first
   theme-change event.

2. Settings notify fires *before* GTK's CSS engine re-resolves styles
   for the new theme. Reading the style context's foreground from
   that handler returned the previous theme's color, so toggling
   light <-> dark left both states settling on the same shade.

Move both responsibilities into a new attach_themed_icon helper in
_icons.py: it does the initial load, connects to the *button's own*
style-updated signal, and rebuilds the icon on each emission. That
signal fires *after* CSS resolution (both on first realize and on
runtime theme switches), so the foreground we read is always the
current one.

A per-button color-key guard skips the rebuild when the resolved
foreground hasn't changed, so unrelated style-updated emissions
(hover, focus, active) don't trigger needless re-renders.

The handler is connected to the button itself, so GTK cleans it up
when the button is destroyed; both editor.py and palette.py drop
their bespoke Gtk.Settings handler bookkeeping.
This commit is contained in:
Ken Sanislo 2026-05-11 00:48:22 -07:00 committed by Peter F. Patel-Schneider
parent c3382b0ba6
commit dfa1cb7ca5
3 changed files with 56 additions and 70 deletions

View File

@ -86,3 +86,48 @@ def themed_icon_image(icon_name: str, style_widget: Gtk.Widget) -> Gtk.Image | N
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)

View File

@ -34,7 +34,7 @@ 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 ._icons import attach_themed_icon # NOQA: E402
from .canvas import KeyboardCanvas # NOQA: E402
from .layout import Layout # NOQA: E402
from .palette import GradientSwatch # NOQA: E402
@ -75,9 +75,6 @@ class PerKeyEditor(Gtk.Box):
# toolbar row
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._tool_buttons: dict[str, Gtk.RadioButton] = {}
# Track which buttons display a themed icon, so we can re-render them
# when the active GTK theme switches (light <-> dark, theme name).
self._themed_icon_buttons: dict[Gtk.RadioButton, str] = {}
self._gradient_swatch: GradientSwatch | None = None
first: Gtk.RadioButton | None = None
supported = layout.supported_tools if layout else ("single", "rect", "bucket", "gradient")
@ -93,12 +90,9 @@ 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 = themed_icon_image(icon_name, btn) if icon_name else None
if image is not None:
btn.add(image)
if icon_name and attach_themed_icon(btn, icon_name) is not None:
btn.set_tooltip_text(tip or label)
btn.get_accessible().set_name(label)
self._themed_icon_buttons[btn] = icon_name
else:
btn.set_label(label)
btn.set_tooltip_text(tip)
@ -108,14 +102,6 @@ class PerKeyEditor(Gtk.Box):
toolbar.pack_start(btn, False, False, 0)
self._tool_buttons[name] = btn
# Re-render themed icons when the GTK theme changes at runtime.
self._theme_signal_handlers: list[tuple[object, int]] = []
if self._themed_icon_buttons:
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))
initial_active, initial_previous = 0xFF0000, 0xFF0000
try:
persisted = sink.palette_state()
@ -165,29 +151,11 @@ class PerKeyEditor(Gtk.Box):
except Exception as e:
logger.debug("perkey sink unsubscribe failed: %s", e)
self._unsubscribe = None
for obj, hid in self._theme_signal_handlers:
try:
obj.disconnect(hid)
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 = themed_icon_image(icon_name, btn)
if new_image is None:
continue
if old is not None:
btn.remove(old)
btn.add(new_image)
new_image.show()
def canvas_size(self) -> tuple[int, int]:
"""Return the canvas's pixel size_request — what the dialog should
size its content area to so the layout fits without scrollbars.

View File

@ -24,8 +24,6 @@ itself — see `GradientSwatch` below, used by `editor.py`.
from __future__ import annotations
import logging
from enum import Enum
import gi
@ -37,9 +35,7 @@ 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__)
from ._icons import attach_themed_icon # NOQA: E402
_UNSET_ICON_NAME = "solaar-tool-palette-off-symbolic"
@ -97,42 +93,19 @@ class Palette(Gtk.Box):
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_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)
unset_label = _("Unset")
if attach_themed_icon(self._unset_btn, _UNSET_ICON_NAME) is not None:
self._unset_btn.get_accessible().set_name(unset_label)
else:
self._unset_btn.set_label(self._unset_label)
self._unset_btn.set_label(unset_label)
self._unset_btn.connect(GtkSignal.TOGGLED.value, self._on_unset_toggled)
self.pack_start(self._unset_btn, False, False, 0)
# 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
# attach_themed_icon connects to the button's own style-updated
# signal; GTK disconnects it automatically when the button is
# destroyed, so there is nothing to clean up here.
pass
def _on_color_set(self, btn: Gtk.ColorButton) -> None:
c = _rgb_to_int(btn.get_rgba())