207 lines
8.3 KiB
Python
207 lines
8.3 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.
|
|
|
|
"""Editor widget: combines toolbar + palette + canvas into one VBox.
|
|
|
|
The editor consumes only the PerKeyColorSink protocol — no device imports,
|
|
no Setting imports — preserving the FE/BE seam.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from enum import Enum
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "3.0")
|
|
from gi.repository import Gtk # NOQA: E402
|
|
|
|
from solaar.i18n import _ # NOQA: E402
|
|
|
|
from . import binding # 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
|
|
from .palette import Palette # NOQA: E402
|
|
from .protocol import PerKeyColorSink # NOQA: E402
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GtkSignal(Enum):
|
|
COLOR_CHANGED = "color-changed"
|
|
PAINT = "paint"
|
|
TOGGLED = "toggled"
|
|
|
|
|
|
_TOOL_LABELS = {
|
|
"single": (_("Brush"), _("Click or drag to paint individual keys")),
|
|
"rect": (_("Rect"), _("Drag to select a rectangle of keys, painted on release")),
|
|
"bucket": (_("Fill"), _("Flood-fill connected keys of the same color with the active color")),
|
|
}
|
|
_TOOL_TOOLTIPS = {
|
|
"gradient": _("Drag to fade from previous color to active color"),
|
|
}
|
|
_TOOL_ICON_NAMES = {
|
|
"single": "solaar-tool-brush-symbolic",
|
|
"rect": "solaar-tool-rect-symbolic",
|
|
"bucket": "solaar-tool-bucket-symbolic",
|
|
}
|
|
|
|
|
|
class PerKeyEditor(Gtk.Box):
|
|
def __init__(self, sink: PerKeyColorSink, layout: Layout | None = None) -> None:
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self._sink = sink
|
|
self._layout = layout
|
|
self._unsubscribe = None
|
|
|
|
# toolbar row
|
|
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
self._tool_buttons: dict[str, Gtk.RadioButton] = {}
|
|
self._gradient_swatch: GradientSwatch | None = None
|
|
first: Gtk.RadioButton | None = None
|
|
supported = layout.supported_tools if layout else ("single", "rect", "bucket", "gradient")
|
|
for name in supported:
|
|
if name == "gradient":
|
|
btn = Gtk.RadioButton.new_from_widget(first)
|
|
btn.set_mode(False)
|
|
self._gradient_swatch = GradientSwatch()
|
|
btn.add(self._gradient_swatch)
|
|
btn.set_tooltip_text(_TOOL_TOOLTIPS["gradient"])
|
|
else:
|
|
label, tip = _TOOL_LABELS.get(name, (name, ""))
|
|
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
|
|
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)
|
|
else:
|
|
btn.set_label(label)
|
|
btn.set_tooltip_text(tip)
|
|
btn.connect(GtkSignal.TOGGLED.value, self._on_tool_toggled, name)
|
|
if first is None:
|
|
first = btn
|
|
toolbar.pack_start(btn, False, False, 0)
|
|
self._tool_buttons[name] = btn
|
|
|
|
initial_active, initial_previous = 0xFF0000, 0xFF0000
|
|
try:
|
|
persisted = sink.palette_state()
|
|
except Exception as e:
|
|
logger.debug("palette_state read failed: %s", e)
|
|
persisted = None
|
|
if persisted is not None:
|
|
initial_active, initial_previous = persisted
|
|
self._palette = Palette(active=initial_active, previous=initial_previous)
|
|
self._palette.connect(GtkSignal.COLOR_CHANGED.value, self._on_color_changed)
|
|
toolbar.pack_end(self._palette, False, False, 0)
|
|
if self._gradient_swatch is not None:
|
|
self._gradient_swatch.update(self._palette.get_color(), self._palette.get_last_color())
|
|
|
|
self.pack_start(toolbar, False, False, 0)
|
|
|
|
# canvas inside a scrolled window so wide layouts can scroll if the
|
|
# window is shrunk below content size. propagate_natural_size lets the
|
|
# window auto-fit small layouts (e.g. an 8-LED mouse) without forcing
|
|
# an oversized minimum.
|
|
scroll = Gtk.ScrolledWindow()
|
|
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
scroll.set_propagate_natural_width(True)
|
|
scroll.set_propagate_natural_height(True)
|
|
# Inset frame around the keyboard so it reads as a distinct panel
|
|
# rather than floating flat against the dialog background.
|
|
scroll.set_shadow_type(Gtk.ShadowType.IN)
|
|
self._canvas = KeyboardCanvas()
|
|
self._canvas.connect(GtkSignal.PAINT.value, self._on_canvas_paint)
|
|
scroll.add(self._canvas)
|
|
self.pack_start(scroll, True, True, 0)
|
|
|
|
self._canvas.set_active_color(self._palette.get_color())
|
|
if self._gradient_swatch is not None:
|
|
self._canvas.set_gradient_colors_source(self._gradient_swatch.get_colors)
|
|
try:
|
|
base = sink.zone_base_color()
|
|
except Exception as e:
|
|
logger.debug("zone_base_color read failed: %s", e)
|
|
base = None
|
|
self._canvas.set_zone_base_color(base)
|
|
self._refresh_layout()
|
|
self._sync_from_sink()
|
|
self._unsubscribe = sink.subscribe(self._on_sink_update)
|
|
|
|
def shutdown(self) -> None:
|
|
if self._unsubscribe:
|
|
try:
|
|
self._unsubscribe()
|
|
except Exception as e:
|
|
logger.debug("perkey sink unsubscribe failed: %s", e)
|
|
self._unsubscribe = None
|
|
try:
|
|
self._palette.shutdown()
|
|
except Exception as e:
|
|
logger.debug("palette shutdown failed: %s", e)
|
|
|
|
def _refresh_layout(self) -> None:
|
|
if self._layout is None:
|
|
# No registered layout: lay out all reported zones as a flat strip.
|
|
from .layout import Cell
|
|
|
|
zones = list(self._sink.zones)
|
|
cells = tuple(Cell(zone_id=z, row=0, col=i, group="strip", label=self._sink.label(z)) for i, z in enumerate(zones))
|
|
self._layout = Layout(cells=cells, rows=1, cols=max(1, len(zones)), description=f"flat strip ({len(zones)} zones)")
|
|
bound = binding.bind(
|
|
self._layout,
|
|
list(self._sink.zones),
|
|
self._sink.label,
|
|
)
|
|
self._canvas.set_layout(bound)
|
|
|
|
def _sync_from_sink(self) -> None:
|
|
self._canvas.set_colors(dict(self._sink.current))
|
|
|
|
def _on_sink_update(self, current: dict[int, int]) -> None:
|
|
self._canvas.set_colors(dict(current))
|
|
|
|
def _on_color_changed(self, _palette, color: int) -> None:
|
|
self._canvas.set_active_color(color)
|
|
# Gradient swatch tracks only real picker colors; toggling unset
|
|
# leaves it alone so the gradient setup isn't disturbed.
|
|
picker = self._palette.get_picker_color()
|
|
if self._gradient_swatch is not None:
|
|
self._gradient_swatch.update(picker, self._palette.get_last_color())
|
|
try:
|
|
self._sink.set_palette_state(picker, self._palette.get_last_color())
|
|
except Exception as e:
|
|
logger.debug("set_palette_state failed: %s", e)
|
|
|
|
def _on_tool_toggled(self, btn: Gtk.RadioButton, name: str) -> None:
|
|
if btn.get_active():
|
|
self._canvas.set_tool(name)
|
|
|
|
def _on_canvas_paint(self, _canvas, delta: dict) -> None:
|
|
if not delta:
|
|
return
|
|
if len(delta) == 1:
|
|
zone, color = next(iter(delta.items()))
|
|
self._sink.write_one(int(zone), int(color))
|
|
else:
|
|
self._sink.write_bulk({int(z): int(c) for z, c in delta.items()})
|