## 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. """Palette: active-color picker + a small gradient swatch widget. The picker (`Palette`) is just a wrapped `Gtk.ColorButton` that emits `color-changed` and remembers the previous active color. The previous color is surfaced visually by the gradient tool button, not in the palette itself — see `GradientSwatch` below, used by `editor.py`. """ from __future__ import annotations from enum import Enum import gi gi.require_version("Gtk", "3.0") from gi.repository import Gdk # NOQA: E402 from gi.repository import GObject # NOQA: E402 from gi.repository import Gtk # NOQA: E402 from solaar.i18n import _ # NOQA: E402 from ._icons import attach_themed_icon # NOQA: E402 _UNSET_ICON_NAME = "solaar-tool-palette-off-symbolic" class GtkSignal(Enum): DRAW = "draw" COLOR_SET = "color-set" TOGGLED = "toggled" def _rgb_to_int(rgba: Gdk.RGBA) -> int: r = max(0, min(255, int(round(rgba.red * 255)))) g = max(0, min(255, int(round(rgba.green * 255)))) b = max(0, min(255, int(round(rgba.blue * 255)))) return (r << 16) | (g << 8) | b def _int_to_rgba(c: int) -> Gdk.RGBA: rgba = Gdk.RGBA() if c is None or c < 0: rgba.red = rgba.green = rgba.blue = 0.5 rgba.alpha = 1.0 return rgba rgba.red = ((c >> 16) & 0xFF) / 255.0 rgba.green = ((c >> 8) & 0xFF) / 255.0 rgba.blue = (c & 0xFF) / 255.0 rgba.alpha = 1.0 return rgba # Sentinel for "no change" / unset paint. Matches special_keys.COLORSPLUS["No change"]. UNSET_COLOR = -1 class Palette(Gtk.Box): __gsignals__ = { "color-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)), } def __init__(self, active: int = 0xFF0000, previous: int = 0xFF0000) -> None: super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) # _color/_last_color are always real RGB values; the unset toggle is # a separate channel so the gradient swatch (which mirrors these) is # unaffected by switching to "no change" paint mode. self._color: int = int(active) self._last_color: int = int(previous) self._unset_mode: bool = False self._color_btn = Gtk.ColorButton() self._color_btn.set_use_alpha(False) self._color_btn.set_rgba(_int_to_rgba(self._color)) self._color_btn.set_tooltip_text(_("Active color")) self._color_btn.connect(GtkSignal.COLOR_SET.value, self._on_color_set) self.pack_start(self._color_btn, False, False, 0) self._unset_btn = Gtk.ToggleButton() self._unset_btn.set_tooltip_text(_("Paint as 'no change' — clears the cell to the zone base color")) 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(unset_label) self._unset_btn.connect(GtkSignal.TOGGLED.value, self._on_unset_toggled) self.pack_start(self._unset_btn, False, False, 0) def shutdown(self) -> None: # 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()) unset_was_on = self._unset_mode if c == self._color and not unset_was_on: return if c != self._color: self._last_color = self._color self._color = c if unset_was_on: self._unset_mode = False self._unset_btn.set_active(False) self.emit("color-changed", self.get_color()) def _on_unset_toggled(self, btn: Gtk.ToggleButton) -> None: new_state = bool(btn.get_active()) if new_state == self._unset_mode: return self._unset_mode = new_state self.emit("color-changed", self.get_color()) def get_color(self) -> int: return UNSET_COLOR if self._unset_mode else self._color def get_picker_color(self) -> int: """The most recent real RGB pick — independent of the unset toggle. Use this for visuals that should always reflect actual colors (e.g. the gradient swatch). """ return self._color def get_last_color(self) -> int: return self._last_color def is_unset(self) -> bool: return self._unset_mode class GradientSwatch(Gtk.DrawingArea): """Small icon: diagonal gradient from `previous` (bottom-left) to `active` (top-right). Used as the visual on the gradient tool button so the user can see at a glance which two colors the next gradient stroke will fade between. """ SIZE = 22 def __init__(self) -> None: super().__init__() self.set_size_request(self.SIZE, self.SIZE) self._active: int = 0xFF0000 self._previous: int = 0xFF0000 self.connect(GtkSignal.DRAW.value, self._on_draw) # Re-render when the GTK theme changes, so the rounded-square # outline (drawn in the theme foreground color) stays in sync # with the tool icons next to it. self.connect("style-updated", lambda w: w.queue_draw()) def update(self, active: int, previous: int) -> None: self._active = int(active) self._previous = int(previous) self.queue_draw() def get_active(self) -> int: return self._active def get_previous(self) -> int: return self._previous def get_colors(self) -> tuple[int, int]: """Return (active, previous) — the colors the gradient tool will paint with.""" return (self._active, self._previous) @staticmethod def _rounded_rect_path(cr, x: float, y: float, w: float, h: float, r: float) -> None: cr.new_sub_path() cr.arc(x + w - r, y + r, r, -1.5708, 0) cr.arc(x + w - r, y + h - r, r, 0, 1.5708) cr.arc(x + r, y + h - r, r, 1.5708, 3.1416) cr.arc(x + r, y + r, r, 3.1416, 4.7124) cr.close_path() def _on_draw(self, _w, cr) -> None: import cairo # local: keeps the module light when GradientSwatch isn't built def rgb(c: int) -> tuple[float, float, float]: if c is None or c < 0: return (0.5, 0.5, 0.5) return (((c >> 16) & 0xFF) / 255.0, ((c >> 8) & 0xFF) / 255.0, (c & 0xFF) / 255.0) # Render in Tabler "square" coordinates (24x24 viewBox, rounded # rect from (3,3) to (21,21), corner radius 2, stroke 2) and let # cairo scale to the swatch's pixel size. Matches the outline # style of the tool icons exactly. cr.save() cr.scale(self.SIZE / 24.0, self.SIZE / 24.0) # Build the rounded-square path once, clip+fill the gradient # inside it, then re-build and stroke the outline in the theme # foreground color. self._rounded_rect_path(cr, 3, 3, 18, 18, 2) cr.save() cr.clip() # Top-left (previous, gradient start) → bottom-right (active, end). # Matches the directional behavior of dragging the line tool TL → BR. # Endpoints are shifted inward by the arc inset (corner radius * (1 # - 1/sqrt(2)), ~0.586 for r=2) so t=0 lands on the actual visible # TL corner pixel of the rounded rect — without this, the rendered # corners sample at t≈0.033/0.967 and the displayed colors are # ~8 RGB units short of the true endpoint colors. inset = 2 * (1 - 1 / (2**0.5)) pat = cairo.LinearGradient(3 + inset, 3 + inset, 21 - inset, 21 - inset) pat.add_color_stop_rgb(0.0, *rgb(self._previous)) pat.add_color_stop_rgb(1.0, *rgb(self._active)) cr.set_source(pat) cr.rectangle(3, 3, 18, 18) cr.fill() cr.restore() # drop clip self._rounded_rect_path(cr, 3, 3, 18, 18, 2) fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL) cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha) cr.set_line_width(2) cr.set_line_join(cairo.LINE_JOIN_ROUND) cr.set_line_cap(cairo.LINE_CAP_ROUND) cr.stroke() cr.restore()