236 lines
8.9 KiB
Python
236 lines
8.9 KiB
Python
## 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()
|