From 3c8c70a5f93050a3d635c69a6fbde6e6aebdc744 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 10 May 2026 01:43:19 -0700 Subject: [PATCH] Add per-key RGB color painter and replace MAP_CHOICE color validator Replace the per-key dropdown UI (MapChoiceControl) with a Cairo-rendered keyboard canvas where users can paint colors directly onto keys. Editor (lib/solaar/ui/perkey/): - Cairo DrawingArea renders cells from a Layout dataclass; bound cells take their painted color, unset cells show a diagonal hash whose base color matches the device's rgb_zone_* setting. - Tools: brush, drag-rectangle, flood-fill (4-adjacent, Paint-style), and a directional gradient (line A->B projected across the matrix with cells past the endpoints clamped to the endpoint colors). - GradientSwatch is the single source of truth for the gradient's two colors; the canvas reads from it on each gradient stroke. - Palette: GTK ColorButton plus an unset toggle that paints the "no change" sentinel (-1). - PerKeyEditorDialog auto-sizes from the canvas's size_request, so a 104-key keyboard opens wide and a 8-LED mouse opens compact. - Editor consumes only a narrow PerKeyColorSink protocol; never imports from lib/logitech_receiver, preserving the FE/BE seam. - Per-device palette state (active + previous color) persists via the existing persister under a _palette: prefixed key. Layouts: - ANSI 104-key full-size and TKL keyboard layouts. - G502 X family mouse layout (zones 1-8 -> labels A-H). - Generic registry: register_layout(feature, matcher, layout). A _name_contains() helper builds case-insensitive substring matchers against device codename / name. Unknown devices fall back to a flat strip of all reported zones. Validator (open value space): - New Range dataclass and MapRangeValidator extending Validator directly (kind=MAP_CHOICE for dispatch compatibility). Replaces the ChoicesMapValidator on PerKeyLighting -- the named-color universe (COLORSPLUS) was rejecting any picker color outside its ~20 entries. Other MAP_CHOICE settings are untouched. Integration: - Setting base gains an editor_class string attribute. config_panel's _create_sbox resolves it via importlib before the kind dispatch, so PerKeyLighting routes to the new editor without a new Kind value. - CLI gains a hex/dec parser for open-value MAP_CHOICE settings: solaar config per-key-lighting A 0xFF00FF - Diversion rule editor skips Range-valued MAP_CHOICE settings' value-selector instead of crashing on the open value space. - pycairo declared in install_requires; transitively present on most systems but now explicit for pip-from-source installs. Tests in test_setting_templates.py updated for the new validator. --- lib/logitech_receiver/settings.py | 4 + lib/logitech_receiver/settings_templates.py | 11 +- lib/logitech_receiver/settings_validator.py | 89 ++++ lib/solaar/cli/config.py | 41 +- lib/solaar/ui/config_panel.py | 19 +- lib/solaar/ui/diversion_rules.py | 10 + lib/solaar/ui/perkey/__init__.py | 22 + lib/solaar/ui/perkey/binding.py | 63 +++ lib/solaar/ui/perkey/canvas.py | 397 ++++++++++++++++++ lib/solaar/ui/perkey/control.py | 251 +++++++++++ lib/solaar/ui/perkey/dialog.py | 87 ++++ lib/solaar/ui/perkey/editor.py | 194 +++++++++ lib/solaar/ui/perkey/layout.py | 102 +++++ lib/solaar/ui/perkey/layouts/__init__.py | 87 ++++ lib/solaar/ui/perkey/layouts/keyboard_ansi.py | 196 +++++++++ lib/solaar/ui/perkey/layouts/mouse_g502x.py | 49 +++ lib/solaar/ui/perkey/palette.py | 255 +++++++++++ lib/solaar/ui/perkey/protocol.py | 73 ++++ lib/solaar/ui/perkey/tools.py | 223 ++++++++++ setup.py | 1 + .../test_setting_templates.py | 36 +- 21 files changed, 2182 insertions(+), 28 deletions(-) create mode 100644 lib/solaar/ui/perkey/__init__.py create mode 100644 lib/solaar/ui/perkey/binding.py create mode 100644 lib/solaar/ui/perkey/canvas.py create mode 100644 lib/solaar/ui/perkey/control.py create mode 100644 lib/solaar/ui/perkey/dialog.py create mode 100644 lib/solaar/ui/perkey/editor.py create mode 100644 lib/solaar/ui/perkey/layout.py create mode 100644 lib/solaar/ui/perkey/layouts/__init__.py create mode 100644 lib/solaar/ui/perkey/layouts/keyboard_ansi.py create mode 100644 lib/solaar/ui/perkey/layouts/mouse_g502x.py create mode 100644 lib/solaar/ui/perkey/palette.py create mode 100644 lib/solaar/ui/perkey/protocol.py create mode 100644 lib/solaar/ui/perkey/tools.py diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index a27d7b06..b5ddd23c 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -61,6 +61,10 @@ class Setting: validator_class = None validator_options = {} display = True # display setting in UI + # Optional UI editor override as "module.path:ClassName". Resolved by the + # config panel before the Kind dispatch. Kept as a string so this module + # stays free of GTK imports — the FE/BE seam is preserved. + editor_class: str | None = None def __init__(self, device, rw, validator): self._device = device diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index ddab8b0a..71a1f2da 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1893,7 +1893,7 @@ class PerKeyLighting(settings.Settings): description = _("Control per-key lighting.") feature = _F.PER_KEY_LIGHTING_V2 keys_universe = special_keys.KEYCODES - choices_universe = special_keys.COLORSPLUS + editor_class = "solaar.ui.perkey.control:PerKeyControl" def read(self, cached=True): self._pre_read(cached) @@ -1950,7 +1950,9 @@ class PerKeyLighting(settings.Settings): class rw_class(settings.FeatureRWMap): pass - class validator_class(settings_validator.ChoicesMapValidator): + class validator_class(settings_validator.MapRangeValidator): + _COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3) + @classmethod def build(cls, setting_class, device): choices_map = {} @@ -1964,9 +1966,8 @@ class PerKeyLighting(settings.Settings): if i in setting_class.keys_universe else common.NamedInt(i, f"KEY {str(i)}") ) - choices_map[key] = setting_class.choices_universe - result = cls(choices_map) if choices_map else None - return result + choices_map[key] = cls._COLOR_RANGE + return cls(choices_map) if choices_map else None # Allow changes to force sensing buttons diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py index d9e3f278..56c2c7a8 100644 --- a/lib/logitech_receiver/settings_validator.py +++ b/lib/logitech_receiver/settings_validator.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import math +from dataclasses import dataclass from enum import IntEnum from logitech_receiver import common @@ -746,3 +747,91 @@ class MultipleRangeValidator(Validator): def compare(self, args, current): logger.warning("compare not implemented for multiple range settings") return False + + +@dataclass(frozen=True) +class Range: + """Inclusive integer range used as the value side of a MapRangeValidator. + + `byte_count` is the wire encoding width. `signed` selects two's-complement. + Settings whose value space is a continuous integer range (e.g. per-key RGB + colors as 24-bit ints) use this in place of a NamedInts choice list. + """ + + min: int + max: int + byte_count: int = 1 + signed: bool = False + + def contains(self, value: int) -> bool: + return isinstance(value, int) and self.min <= value <= self.max + + +class MapRangeValidator(Validator): + """Map of keys → integer in a per-key Range. Open value space (no choice list). + + Reports `kind = Kind.MAP_CHOICE` so the existing config-panel/CLI/rule-engine + dispatch keeps routing without new branches; consumers that need to tell + "choice list" from "open range" check `isinstance(setting.choices[k], Range)`. + + TODO: complete `Kind.MAP_RANGE` infrastructure (UI dispatch, generic rule-UI + handling, generalize `cli/config.py:299`) and migrate this validator's + `kind` over. Today MAP_RANGE is only honored by `ForceSensing` via the + `settings_new` framework; bridging both frameworks is a separate task. + """ + + kind = Kind.MAP_CHOICE + + def __init__(self, choices_map, key_byte_count=1, write_prefix_bytes=b""): + assert isinstance(choices_map, dict) + for k, v in choices_map.items(): + assert isinstance(k, NamedInt), f"MapRangeValidator key must be NamedInt, got {type(k).__name__}" + assert isinstance(v, Range), f"MapRangeValidator value must be Range, got {type(v).__name__}" + self.choices = choices_map + self.needs_current_value = False + self._key_byte_count = key_byte_count + self._write_prefix_bytes = write_prefix_bytes + + def to_string(self, value) -> str: + if not isinstance(value, dict): + return str(value) + return "{" + ", ".join(f"{k}:{value[k]}" for k in sorted(value)) + "}" + + def validate_read(self, reply_bytes, key): + rng = self.choices.get(key) + if rng is None: + return None + end = self._key_byte_count + rng.byte_count + return common.bytes2int(reply_bytes[self._key_byte_count : end], signed=rng.signed) + + def prepare_key(self, key): + return int(key).to_bytes(self._key_byte_count, "big") + + def prepare_write(self, key, new_value): + rng = self.choices.get(key) + if rng is None: + logger.error("invalid key %r for map-range setting", key) + return None + if not rng.contains(new_value): + logger.error("value %r out of range [%d, %d] for key %s", new_value, rng.min, rng.max, key) + return None + return self._write_prefix_bytes + int(new_value).to_bytes(rng.byte_count, "big", signed=rng.signed) + + def acceptable(self, args, current): + if not isinstance(args, list) or len(args) != 2: + return None + key = next((k for k in self.choices if int(k) == int(args[0])), None) + if key is None: + return None + rng = self.choices[key] + if not rng.contains(args[1]): + return None + return [int(key), int(args[1])] + + def compare(self, args, current): + if not isinstance(args, list) or len(args) != 2 or not isinstance(current, dict): + return False + key = next((k for k in self.choices if int(k) == int(args[0])), None) + if key is None: + return False + return current.get(int(key)) == args[1] diff --git a/lib/solaar/cli/config.py b/lib/solaar/cli/config.py index 1ac047a9..f9dbb270 100644 --- a/lib/solaar/cli/config.py +++ b/lib/solaar/cli/config.py @@ -20,12 +20,28 @@ from logitech_receiver import settings from logitech_receiver import settings_templates from logitech_receiver.common import NamedInts from logitech_receiver.settings_templates import SettingsProtocol +from logitech_receiver.settings_validator import Range from solaar import configuration APP_ID = "io.github.pwr_solaar.solaar" +def _parse_int_or_hex(s) -> int | None: + """Parse 0xRRGGBB / #RRGGBB / decimal int. Returns None on bad input.""" + if not isinstance(s, str): + return None + s = s.strip() + try: + if s.startswith("#"): + return int(s[1:], 16) + if s.lower().startswith("0x"): + return int(s, 16) + return int(s, 10) + except ValueError: + return None + + def _print_setting(s, verbose=True): print("#", s.label) if verbose: @@ -70,7 +86,11 @@ def _print_setting_keyed(s, key, verbose=True): if k is None: print(s.name, "=? (key not found)") else: - print("# possible values: one of [", ", ".join(str(v) for v in s.choices[k]), "]") + value_space = s.choices[k] + if isinstance(value_space, Range): + print(f"# possible values: integer in [{value_space.min}, {value_space.max}] (decimal or 0xHEX)") + else: + print("# possible values: one of [", ", ".join(str(v) for v in value_space), "]") value = s.read(cached=False) if value is None: print(s.name, "= ? (failed to read from device)") @@ -245,12 +265,21 @@ def set(dev, setting: SettingsProtocol, args, save): k = next((k for k in setting.choices.keys() if key == k), None) if k is None and ikey is not None: k = next((k for k in setting.choices.keys() if ikey == k), None) - if k is not None: - value = select_choice(args.extra_subkey, setting.choices[k], setting, key) - args.extra_subkey = int(value) - args.value_key = str(int(k)) - else: + if k is None: raise Exception(f"{setting.name}: key '{key}' not in setting") + value_space = setting.choices[k] + if isinstance(value_space, Range): + ivalue = _parse_int_or_hex(args.extra_subkey) + if ivalue is None or not value_space.contains(ivalue): + raise Exception( + f"{setting.name}: value '{args.extra_subkey}' must be an integer in " + f"[{value_space.min}, {value_space.max}] (decimal or 0xHEX / #HEX)" + ) + value = ivalue + else: + value = select_choice(args.extra_subkey, value_space, setting, key) + args.extra_subkey = int(value) + args.value_key = str(int(k)) message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}" result = setting.write_key_value(int(k), value, save=save) diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index 7622dcba..990293da 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -764,7 +764,24 @@ def _create_sbox(s, _device): change.set_sensitive(True) change.connect(GtkSignal.CLICKED.value, _change_click, sbox) - if s.kind == settings.Kind.TOGGLE: + editor_path = getattr(s, "editor_class", None) + if editor_path: + try: + mod_name, _sep, cls_name = editor_path.partition(":") + import importlib + + mod = importlib.import_module(mod_name) + cls = getattr(mod, cls_name) + control = cls(sbox) + except Exception as e: + logger.warning("setting %s editor_class %r failed (%s); falling back to default", s.name, editor_path, repr(e)) + control = None + else: + control = None + + if control is not None: + pass + elif s.kind == settings.Kind.TOGGLE: control = ToggleControl(sbox) elif s.kind == settings.Kind.RANGE: control = SliderControl(sbox) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 02847afb..6e2556fc 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -42,6 +42,7 @@ from logitech_receiver.common import UnsortedNamedInts from logitech_receiver.settings import Kind from logitech_receiver.settings import Setting from logitech_receiver.settings_templates import SETTINGS +from logitech_receiver.settings_validator import Range from solaar.i18n import _ from solaar.ui import rule_actions @@ -1675,6 +1676,15 @@ class _SettingWithValueUI: if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE): self.value_field.make_toggle() elif kind in (Kind.CHOICE, Kind.MAP_CHOICE): + # Open-value-space MAP_CHOICE settings (per-key RGB) have a Range + # rather than a NamedInts value list — there's no meaningful value + # picker to render in the rule editor, so fall through to unsupported. + if kind == Kind.MAP_CHOICE and device_setting: + val = device_setting._validator + choices = getattr(val, "choices", None) + if isinstance(choices, dict) and any(isinstance(v, Range) for v in choices.values()): + self.value_field.make_unsupported() + return all_values, extra = self._all_choices(device_setting or setting_name) self.value_field.make_choice(all_values, extra) supported_values = None diff --git a/lib/solaar/ui/perkey/__init__.py b/lib/solaar/ui/perkey/__init__.py new file mode 100644 index 00000000..28a39a5c --- /dev/null +++ b/lib/solaar/ui/perkey/__init__.py @@ -0,0 +1,22 @@ +## Copyright (C) 2024 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. + +from .layout import Cell +from .layout import Layout +from .layouts import layout_for +from .layouts import register_layout + +__all__ = ("Cell", "Layout", "layout_for", "register_layout") diff --git a/lib/solaar/ui/perkey/binding.py b/lib/solaar/ui/perkey/binding.py new file mode 100644 index 00000000..e3892c83 --- /dev/null +++ b/lib/solaar/ui/perkey/binding.py @@ -0,0 +1,63 @@ +## Copyright (C) 2024 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. + +"""Bind a Layout to a sink's reported zone list. + +Cells whose `zone_id` the device reports are marked bound. Cells whose zone +the device does not report stay disabled (greyed). Device-reported zones not +covered by any cell get synthesized as strip cells using the sink's labels — +this catches G-keys, logo, media keys and any device-specific extras. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from .layout import BoundCell +from .layout import BoundLayout +from .layout import Cell +from .layout import Layout + + +def bind(layout: Layout, zones: list[int], label_for: Callable[[int], str]) -> BoundLayout: + reported = set(zones) + claimed: set[int] = set() + matrix: list[BoundCell] = [] + strip: list[BoundCell] = [] + for c in layout.matrix_cells(): + bound = c.zone_id in reported + if bound: + claimed.add(c.zone_id) + matrix.append(BoundCell(cell=c, bound=bound)) + for c in layout.strip_cells(): + bound = c.zone_id in reported + if bound: + claimed.add(c.zone_id) + strip.append(BoundCell(cell=c, bound=bound)) + unmapped_all = tuple(z for z in zones if z not in claimed) + # Filter unmapped zones through the layout's curated allowlist. Without + # this, firmware-reported phantoms (G515 reports 47, 97, 99-103, 254) + # would surface as paintable strip cells that don't address any LED. + if layout.extra_zones is None: + showable = unmapped_all + else: + showable = tuple(z for z in unmapped_all if z in layout.extra_zones) + next_col = max((bc.cell.col for bc in strip), default=-1) + 1 + for z in showable: + synth = Cell(zone_id=z, row=0, col=next_col, group="strip", label=label_for(z)) + strip.append(BoundCell(cell=synth, bound=True)) + next_col += 1 + return BoundLayout(matrix=tuple(matrix), strip=tuple(strip), unmapped=unmapped_all) diff --git a/lib/solaar/ui/perkey/canvas.py b/lib/solaar/ui/perkey/canvas.py new file mode 100644 index 00000000..74b14e78 --- /dev/null +++ b/lib/solaar/ui/perkey/canvas.py @@ -0,0 +1,397 @@ +## Copyright (C) 2024 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. + +"""Cairo-rendered keyboard canvas. Renders a BoundLayout as colored rectangles +and dispatches paint events through a configurable Tool to the editor. +""" + +from __future__ import annotations + +import logging + +from enum import Enum +from typing import Callable + +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 .layout import BoundCell # NOQA: E402 +from .layout import BoundLayout # NOQA: E402 +from .tools import TOOLS # NOQA: E402 +from .tools import ToolContext # NOQA: E402 + +logger = logging.getLogger(__name__) + + +class GtkSignal(Enum): + DRAW = "draw" + BUTTON_PRESS_EVENT = "button-press-event" + BUTTON_RELEASE_EVENT = "button-release-event" + MOTION_NOTIFY_EVENT = "motion-notify-event" + LEAVE_NOTIFY_EVENT = "leave-notify-event" + + +CELL_PX = 36 +GUTTER_PX = 4 +STRIP_GAP_PX = 16 +PADDING_PX = 8 + + +class KeyboardCanvas(Gtk.DrawingArea): + __gsignals__ = { + # Emitted on stroke release. delta is dict[zone_id, color]. + "paint": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + } + + def __init__(self) -> None: + super().__init__() + self._bound: BoundLayout | None = None + self._colors: dict[int, int] = {} # zone_id -> packed RGB or -1 (unset) + self._active_color: int = 0xFF0000 + self._gradient_colors_source: Callable[[], tuple[int, int]] | None = None + self._zone_base_color: int | None = None + self._tool_name: str = "single" + self._press_cell: BoundCell | None = None + self._motion_cell: BoundCell | None = None + self._brush_path: list[int] = [] + self._dragging: bool = False + self.set_can_focus(True) + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.connect(GtkSignal.DRAW.value, self._on_draw) + self.connect(GtkSignal.BUTTON_PRESS_EVENT.value, self._on_press) + self.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._on_release) + self.connect(GtkSignal.MOTION_NOTIFY_EVENT.value, self._on_motion) + self.connect(GtkSignal.LEAVE_NOTIFY_EVENT.value, self._on_leave) + + # ---- public API ---- + + def set_layout(self, bound: BoundLayout) -> None: + self._bound = bound + self._update_size() + self.queue_draw() + + def set_colors(self, colors: dict[int, int]) -> None: + self._colors = dict(colors) + self.queue_draw() + + def update_colors(self, deltas: dict[int, int]) -> None: + self._colors.update(deltas) + self.queue_draw() + + def set_active_color(self, color: int) -> None: + self._active_color = int(color) + + def set_gradient_colors_source(self, source: Callable[[], tuple[int, int]] | None) -> None: + self._gradient_colors_source = source + + def set_zone_base_color(self, color: int | None) -> None: + self._zone_base_color = None if color is None else int(color) + self.queue_draw() + + def set_tool(self, name: str) -> None: + if name in TOOLS: + self._tool_name = name + + # ---- size / hit-test ---- + + def _matrix_size(self) -> tuple[int, int]: + if not self._bound: + return 0, 0 + max_col = 0 + max_row = 0 + for bc in self._bound.matrix: + c = bc.cell + max_col = max(max_col, c.col + int(round(c.width))) + max_row = max(max_row, c.row + int(round(c.height))) + return max_row, max_col + + def _strip_size(self) -> int: + if not self._bound: + return 0 + return len(self._bound.strip) + + def _update_size(self) -> None: + rows, cols = self._matrix_size() + strip_n = self._strip_size() + w = PADDING_PX * 2 + cols * CELL_PX + max(0, cols - 1) * GUTTER_PX + matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX + strip_h = (CELL_PX + STRIP_GAP_PX) if strip_n else 0 + h = PADDING_PX * 2 + matrix_h + strip_h + # widen if strip is wider than matrix + if strip_n: + sw = PADDING_PX * 2 + strip_n * CELL_PX + max(0, strip_n - 1) * GUTTER_PX + w = max(w, sw) + self.set_size_request(w, h) + + def _cell_rect(self, bc: BoundCell) -> tuple[float, float, float, float]: + c = bc.cell + if self._bound is not None and bc in self._bound.strip: + # strip cells: laid out in a flat row beneath the matrix + rows, _cols = self._matrix_size() + matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX + strip_idx = self._bound.strip.index(bc) + x = PADDING_PX + strip_idx * (CELL_PX + GUTTER_PX) + y = PADDING_PX + matrix_h + STRIP_GAP_PX + return (x, y, CELL_PX, CELL_PX) + x = PADDING_PX + c.col * (CELL_PX + GUTTER_PX) + y = PADDING_PX + c.row * (CELL_PX + GUTTER_PX) + w = c.width * CELL_PX + max(0.0, c.width - 1.0) * GUTTER_PX + h = c.height * CELL_PX + max(0.0, c.height - 1.0) * GUTTER_PX + return (x, y, w, h) + + def _cell_at(self, x: float, y: float) -> BoundCell | None: + if not self._bound: + return None + for bc in list(self._bound.matrix) + list(self._bound.strip): + cx, cy, cw, ch = self._cell_rect(bc) + if cx <= x < cx + cw and cy <= y < cy + ch: + return bc + return None + + # ---- draw ---- + + def _on_draw(self, _widget, cr) -> bool: + if not self._bound: + return False + for bc in self._bound.matrix: + self._draw_cell(cr, bc) + for bc in self._bound.strip: + self._draw_cell(cr, bc) + if self._dragging and self._press_cell and self._motion_cell: + tool = TOOLS.get(self._tool_name) + if tool is not None: + if tool.overlay_shape == "rect": + self._draw_rect_overlay(cr, self._press_cell, self._motion_cell) + elif tool.overlay_shape == "line": + self._draw_line_overlay(cr, self._press_cell, self._motion_cell) + return False + + def _draw_cell(self, cr, bc: BoundCell) -> None: + x, y, w, h = self._cell_rect(bc) + color = self._colors.get(bc.cell.zone_id, -1) + # background + if not bc.bound: + cr.set_source_rgba(0.18, 0.18, 0.20, 1.0) + elif color is None or color < 0: + self._fill_checker(cr, x, y, w, h) + cr.set_source_rgba(0, 0, 0, 0) # no overlay fill + else: + r = ((color >> 16) & 0xFF) / 255.0 + g = ((color >> 8) & 0xFF) / 255.0 + b = (color & 0xFF) / 255.0 + cr.set_source_rgba(r, g, b, 1.0) + if bc.bound and (color is not None and color >= 0): + self._round_rect(cr, x, y, w, h, 4) + cr.fill_preserve() + elif not bc.bound: + self._round_rect(cr, x, y, w, h, 4) + cr.fill_preserve() + else: + self._round_rect(cr, x, y, w, h, 4) + # border + cr.set_source_rgba(0, 0, 0, 0.55) + cr.set_line_width(1.0) + cr.stroke() + # label + label = bc.cell.label or str(bc.cell.zone_id) + cr.set_source_rgba(*self._label_color(color, bc.bound)) + cr.select_font_face("Sans") + cr.set_font_size(11.0 if len(label) <= 3 else 9.0) + try: + extents = cr.text_extents(label) + tx = x + (w - extents.width) / 2 - extents.x_bearing + ty = y + (h + extents.height) / 2 - extents.y_bearing - extents.height + cr.move_to(tx, ty) + cr.show_text(label) + except Exception as e: + logger.debug("text rendering failed for %r: %s", label, e) + + def _fill_checker(self, cr, x, y, w, h) -> None: + # Diagonal hash for "no change" cells. Background uses the zone base + # color (what these cells actually display on the keyboard); stripes + # pick a black or white contrast based on luminance. + cr.save() + self._round_rect(cr, x, y, w, h, 4) + cr.clip() + base = self._zone_base_color + if base is not None and base >= 0: + r = ((base >> 16) & 0xFF) / 255.0 + g = ((base >> 8) & 0xFF) / 255.0 + b = (base & 0xFF) / 255.0 + else: + r = g = 0.30 + b = 0.32 + cr.set_source_rgba(r, g, b, 1.0) + cr.rectangle(x, y, w, h) + cr.fill() + if base is not None and base >= 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.5) + step = 5 + d_max = int(w + h) + d = -int(h) + while d <= d_max: + cr.move_to(x + d, y + h) + cr.line_to(x + d + h, y) + cr.stroke() + d += step + cr.restore() + + def _round_rect(self, cr, x, y, w, h, r) -> 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 _label_color(self, color: int, bound: bool) -> tuple[float, float, float, float]: + if not bound: + return (0.50, 0.50, 0.52, 1.0) + if color is None or color < 0: + return (0.85, 0.85, 0.88, 1.0) + # luminance heuristic + r = ((color >> 16) & 0xFF) / 255.0 + g = ((color >> 8) & 0xFF) / 255.0 + b = (color & 0xFF) / 255.0 + lum = 0.299 * r + 0.587 * g + 0.114 * b + return (0, 0, 0, 1.0) if lum > 0.55 else (1, 1, 1, 1.0) + + def _draw_rect_overlay(self, cr, a: BoundCell, b: BoundCell) -> None: + ax, ay, aw, ah = self._cell_rect(a) + bx, by, bw, bh = self._cell_rect(b) + x0 = min(ax, bx) - 2 + y0 = min(ay, by) - 2 + x1 = max(ax + aw, bx + bw) + 2 + y1 = max(ay + ah, by + bh) + 2 + cr.set_source_rgba(0.30, 0.65, 1.0, 0.85) + cr.set_line_width(1.5) + cr.set_dash([4.0, 3.0]) + cr.rectangle(x0, y0, x1 - x0, y1 - y0) + cr.stroke() + cr.set_dash([]) + + def _draw_line_overlay(self, cr, a: BoundCell, b: BoundCell) -> None: + ax, ay, aw, ah = self._cell_rect(a) + bx, by, bw, bh = self._cell_rect(b) + ax_c, ay_c = ax + aw / 2, ay + ah / 2 + bx_c, by_c = bx + bw / 2, by + bh / 2 + cr.set_source_rgba(0.30, 0.65, 1.0, 0.95) + cr.set_line_width(2.0) + cr.set_dash([5.0, 3.0]) + cr.move_to(ax_c, ay_c) + cr.line_to(bx_c, by_c) + cr.stroke() + cr.set_dash([]) + # endpoint dots — solid so the anchors read clearly + for cx, cy in ((ax_c, ay_c), (bx_c, by_c)): + cr.arc(cx, cy, 4.0, 0, 6.283) + cr.fill() + + # ---- input ---- + + def _on_press(self, _w, event: Gdk.EventButton) -> bool: + if event.button != 1: + return False + bc = self._cell_at(event.x, event.y) + if bc is None or not bc.bound: + return False + self._press_cell = bc + self._motion_cell = bc + self._dragging = True + self._brush_path = [bc.cell.zone_id] + tool = TOOLS.get(self._tool_name) + if tool is not None and tool.is_brush: + self.update_colors({bc.cell.zone_id: self._active_color}) + else: + self.queue_draw() + return True + + def _on_motion(self, _w, event: Gdk.EventMotion) -> bool: + if not self._dragging: + return False + bc = self._cell_at(event.x, event.y) + if bc is None or not bc.bound: + return False + if bc is self._motion_cell: + return False + self._motion_cell = bc + tool = TOOLS.get(self._tool_name) + if tool is not None and tool.is_brush: + if bc.cell.zone_id not in self._brush_path: + self._brush_path.append(bc.cell.zone_id) + self.update_colors({bc.cell.zone_id: self._active_color}) + else: + self.queue_draw() + return True + + def _on_release(self, _w, event: Gdk.EventButton) -> bool: + if event.button != 1 or not self._dragging: + return False + self._dragging = False + if self._press_cell is None: + return False + if self._bound: + bound_zones = {bc.cell.zone_id: bc for bc in list(self._bound.matrix) + list(self._bound.strip)} + strip_zones = frozenset(bc.cell.zone_id for bc in self._bound.strip) + else: + bound_zones = {} + strip_zones = frozenset() + if self._tool_name == "gradient" and self._gradient_colors_source is not None: + grad_active, grad_previous = self._gradient_colors_source() + ctx = ToolContext( + active_color=int(grad_active), + last_color=int(grad_previous), + cells_by_zone=bound_zones, + strip_zones=strip_zones, + current_colors=dict(self._colors), + ) + else: + ctx = ToolContext( + active_color=self._active_color, + last_color=self._active_color, + cells_by_zone=bound_zones, + strip_zones=strip_zones, + current_colors=dict(self._colors), + ) + tool = TOOLS.get(self._tool_name) + delta: dict[int, int] = {} + if tool is not None: + delta = tool.compute(self._press_cell, self._motion_cell, list(self._brush_path), ctx) + self._press_cell = None + self._motion_cell = None + self._brush_path = [] + self.queue_draw() + if delta: + self.update_colors(delta) + self.emit("paint", delta) + return True + + def _on_leave(self, _w, _event) -> bool: + # don't cancel drags on leave; let the user re-enter + return False diff --git a/lib/solaar/ui/perkey/control.py b/lib/solaar/ui/perkey/control.py new file mode 100644 index 00000000..a209ceec --- /dev/null +++ b/lib/solaar/ui/perkey/control.py @@ -0,0 +1,251 @@ +## Copyright (C) 2024 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. + +"""Inline placeholder control replacing MapChoiceControl for opted-in settings. + +Renders a summary line + button. Click opens the per-key editor dialog, +backed by a SettingSink adapter that bridges the editor protocol to the +Solaar Setting object. The editor never touches the Setting directly. +""" + +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 dialog as dialog_mod # NOQA: E402 +from .layouts import layout_for # NOQA: E402 + +logger = logging.getLogger(__name__) + + +class GtkSignal(Enum): + CLICKED = "clicked" + + +# Sentinel matching special_keys.COLORSPLUS["No change"]. +NO_CHANGE = -1 + + +class _SettingSink: + """Bridge between a Solaar Setting and the editor's PerKeyColorSink protocol.""" + + def __init__(self, setting, sbox) -> None: + self._setting = setting + self._sbox = sbox + self._listeners: list = [] + + @property + def title(self) -> str: + device = getattr(self._setting, "_device", None) + name = getattr(device, "name", None) or getattr(device, "codename", None) or "" + return name or self._setting.label + + @property + def zones(self) -> list[int]: + return [int(k) for k in self._setting.choices] + + @property + def current(self) -> dict[int, int]: + return dict(self._setting._value or {}) + + def label(self, zone: int) -> str: + for k in self._setting.choices: + if int(k) == int(zone): + return str(k) + return f"KEY {zone}" + + def write_one(self, zone: int, color: int) -> None: + if self._setting._value is None: + self._setting._value = {} + self._setting._value[int(zone)] = int(color) + # Lazy import to avoid a circular module-load between config_panel and perkey. + from solaar.ui.config_panel import _write_async + + _write_async(self._setting, int(color), self._sbox, key=int(zone)) + self._notify() + + def write_bulk(self, deltas: dict[int, int]) -> None: + if not deltas: + return + if self._setting._value is None: + self._setting._value = {} + merged = dict(self._setting._value) + merged.update({int(k): int(v) for k, v in deltas.items()}) + self._setting._value = merged + from solaar.ui.config_panel import _write_async + + _write_async(self._setting, merged, self._sbox, key=None) + self._notify() + + def subscribe(self, listener): + self._listeners.append(listener) + + def unsubscribe() -> None: + # Idempotent: the editor calls this on shutdown, but the listener + # may already be gone if the sink itself was torn down first. + try: + self._listeners.remove(listener) + except ValueError: + pass + + return unsubscribe + + def _palette_key(self) -> str: + return f"_palette:{self._setting.name}" + + def palette_state(self) -> tuple[int, int] | None: + device = getattr(self._setting, "_device", None) + persister = getattr(device, "persister", None) + if persister is None: + return None + entry = persister.get(self._palette_key()) + if not isinstance(entry, dict): + return None + active = entry.get("active") + previous = entry.get("previous", active) + if not isinstance(active, int) or not isinstance(previous, int): + return None + return (int(active), int(previous)) + + def set_palette_state(self, active: int, previous: int) -> None: + device = getattr(self._setting, "_device", None) + persister = getattr(device, "persister", None) + if persister is None: + return + persister[self._palette_key()] = {"active": int(active), "previous": int(previous)} + + def zone_base_color(self) -> int | None: + device = getattr(self._setting, "_device", None) + if device is None or not getattr(device, "settings", None): + return None + for s in device.settings: + if s.name.startswith("rgb_zone_") and s._value is not None: + color = getattr(s._value, "color", None) + if isinstance(color, int): + return int(color) + return None + + def _notify(self) -> None: + snapshot = self.current + for cb in list(self._listeners): + try: + cb(snapshot) + except Exception as e: + logger.warning("perkey listener raised: %s", e) + + def push_external_value(self, value) -> None: + """Called from the inline control when the framework reports a value change.""" + if isinstance(value, dict): + self._notify() + + +class PerKeyControl(Gtk.Box): + """Replaces MapChoiceControl for per-key color settings. + + Ducktypes the four `Control` methods (`set_sensitive`, `set_value`, + `get_value`, `layout`) used by `_create_sbox` / `_update_setting_item`. + """ + + def __init__(self, sbox) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.sbox = sbox + self._setting = sbox.setting + self._value: dict | None = None + self._sink = _SettingSink(self._setting, sbox) + + self._summary = Gtk.Label(label=_("(not loaded)")) + self._summary.set_xalign(0.0) + self.pack_start(self._summary, True, True, 0) + + self._open_btn = Gtk.Button(label=_("Open editor…")) + self._open_btn.set_tooltip_text(_("Paint key colors on a keyboard layout")) + self._open_btn.connect(GtkSignal.CLICKED.value, self._on_open) + self.pack_end(self._open_btn, False, False, 0) + + # ---- Control protocol ---- + + def set_sensitive(self, sensitive: bool) -> None: + super().set_sensitive(bool(sensitive)) + self._open_btn.set_sensitive(bool(sensitive)) + + def set_value(self, value) -> None: + if value is None: + return + if not isinstance(value, dict): + return + # _write_async wraps single-key writes as `{key: written_value}` so + # MapChoiceControl can update one combo cell. We need to keep the + # full picture for the summary count, so merge instead of replace + # when a partial dict comes in. + existing = self._setting._value if isinstance(self._setting._value, dict) else None + if existing and len(value) < len(existing): + merged = dict(existing) + merged.update(value) + self._value = merged + else: + self._value = value + self._sink.push_external_value(self._value) + self._refresh_summary() + + def get_value(self): + return self._value + + def layout(self, sbox, label, change, spinner, failed) -> bool: + # Match the standard Control packing order so our button sits where + # every other setting's widget sits, just left of spinner/change-icon. + sbox.pack_start(label, False, False, 0) + sbox.pack_end(change, False, False, 0) + sbox.pack_end(self, False, False, 0) + sbox.pack_end(spinner, False, False, 0) + sbox.pack_end(failed, False, False, 0) + return self + + # ---- internal ---- + + def _refresh_summary(self) -> None: + if not isinstance(self._value, dict): + self._summary.set_text(_("(no zones)")) + return + total = len(self._value) + painted = sum(1 for v in self._value.values() if isinstance(v, int) and v != NO_CHANGE and v >= 0) + self._summary.set_text(_("{painted} / {total} keys painted").format(painted=painted, total=total)) + + def _on_open(self, _btn) -> None: + feature = getattr(self._setting, "feature", None) + feature_int = int(feature) if feature is not None else 0 + device = getattr(self._setting, "_device", None) + kind_obj = getattr(device, "kind", None) + kind_str = str(kind_obj).lower() if kind_obj is not None else "" + hint = { + "kind": kind_str if kind_str else None, + "wpid": getattr(device, "wpid", None), + "codename": getattr(device, "codename", None), + "name": getattr(device, "name", None), + "zones": list(self._sink.zones), + "zone_count": len(self._sink.zones), + } + layout = layout_for(feature_int, hint) + dlg = dialog_mod.get_dialog() + dlg.present(self._sink, layout) diff --git a/lib/solaar/ui/perkey/dialog.py b/lib/solaar/ui/perkey/dialog.py new file mode 100644 index 00000000..3c8528b2 --- /dev/null +++ b/lib/solaar/ui/perkey/dialog.py @@ -0,0 +1,87 @@ +## Copyright (C) 2024 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. + +"""Singleton dialog hosting a PerKeyEditor for one sink at a time.""" + +from __future__ import annotations + +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 .editor import PerKeyEditor # NOQA: E402 +from .layout import Layout # NOQA: E402 +from .protocol import PerKeyColorSink # NOQA: E402 + + +class GtkSignal(Enum): + DELETE_EVENT = "delete-event" + + +class PerKeyEditorDialog: + _instance: "PerKeyEditorDialog | None" = None + + def __init__(self) -> None: + self._window = Gtk.Window() + self._window.set_title(_("Per-key Lighting")) + # No default size or geometry hints — the editor's content size + # (driven by KeyboardCanvas's size_request) determines the window size + # via the ScrolledWindow's propagate_natural_size. Wide keyboards open + # large; small mice open small. + self._window.connect(GtkSignal.DELETE_EVENT.value, self._on_delete) + self._editor: PerKeyEditor | None = None + self._wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self._wrapper.set_border_width(8) + self._window.add(self._wrapper) + + def _on_delete(self, _w, _e) -> bool: + self._window.hide() + if self._editor is not None: + self._editor.shutdown() + self._wrapper.remove(self._editor) + self._editor = None + return True + + def present(self, sink: PerKeyColorSink, layout: Layout | None) -> None: + if self._editor is not None: + self._editor.shutdown() + self._wrapper.remove(self._editor) + self._editor = None + self._editor = PerKeyEditor(sink, layout) + self._wrapper.pack_start(self._editor, True, True, 0) + self._wrapper.show_all() + self._window.set_title(_("Per-key Lighting") + " — " + sink.title) + # Resize to fit canvas + toolbar. ScrolledWindow's *minimum* is tiny + # (one row's worth) regardless of propagate_natural_size, so we have + # to compute the target ourselves from the canvas's size_request. + canvas_w, canvas_h = self._editor.canvas_size() + if canvas_w > 0 and canvas_h > 0: + # Toolbar ~50px, wrapper border 8px each side, scrollbar slack ~4. + target_w = canvas_w + 32 + target_h = canvas_h + 80 + self._window.resize(target_w, target_h) + self._window.present() + + +def get_dialog() -> PerKeyEditorDialog: + if PerKeyEditorDialog._instance is None: + PerKeyEditorDialog._instance = PerKeyEditorDialog() + return PerKeyEditorDialog._instance diff --git a/lib/solaar/ui/perkey/editor.py b/lib/solaar/ui/perkey/editor.py new file mode 100644 index 00000000..71de9444 --- /dev/null +++ b/lib/solaar/ui/perkey/editor.py @@ -0,0 +1,194 @@ +## Copyright (C) 2024 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 .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"), +} + + +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, "")) + btn = Gtk.RadioButton.new_with_label_from_widget(first, label) + btn.set_mode(False) # render as toggle button rather than radio + 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) + 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._palette.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 + + 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. + """ + return self._canvas.get_size_request() + + 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()}) diff --git a/lib/solaar/ui/perkey/layout.py b/lib/solaar/ui/perkey/layout.py new file mode 100644 index 00000000..86c21dd9 --- /dev/null +++ b/lib/solaar/ui/perkey/layout.py @@ -0,0 +1,102 @@ +## Copyright (C) 2024 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. + +"""Visual layout primitives for the per-key color editor. + +This module is pure data. It does not import GTK and does not import from +`lib.logitech_receiver`. It is therefore relocatable into a shared package +when the frontend/backend split happens. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field + + +@dataclass(frozen=True) +class Cell: + """One paintable cell in a layout. + + `zone_id` is the firmware identifier the device uses for this LED. It is + matched against the device's reported zone list at bind time; cells with + no matching device zone are drawn disabled. + """ + + zone_id: int + row: int + col: int + width: float = 1.0 + height: float = 1.0 + group: str = "main" + label: str = "" + x: float | None = None + y: float | None = None + + +@dataclass(frozen=True) +class Layout: + """A device-class visual layout. + + Cells in `strip_groups` are rendered as a flat row beneath the matrix + region, regardless of their row/col fields. Cells outside `strip_groups` + are placed by row/col on the main matrix. + + `extra_zones` is a curated allowlist of zone ids that may appear in the + bottom strip when the device reports them but they are not covered by a + layout cell. Zones outside the allowlist are dropped — Logitech firmware + bitmaps enumerate phantom/reserved slots (e.g. G515 reports 47, 97, 99-103, + 254) that aren't physical keys. Set to `None` to disable filtering. + """ + + cells: tuple[Cell, ...] + rows: int + cols: int + strip_groups: tuple[str, ...] = ("strip",) + supported_tools: tuple[str, ...] = ("single", "rect", "bucket", "gradient") + extra_zones: frozenset[int] | None = None + description: str = "" + + def matrix_cells(self) -> tuple[Cell, ...]: + return tuple(c for c in self.cells if c.group not in self.strip_groups) + + def strip_cells(self) -> tuple[Cell, ...]: + return tuple(c for c in self.cells if c.group in self.strip_groups) + + def by_zone(self) -> dict[int, Cell]: + return {c.zone_id: c for c in self.cells} + + +@dataclass(frozen=True) +class BoundCell: + """A Cell augmented with bind state, returned by `binding.bind`.""" + + cell: Cell + bound: bool + + +@dataclass(frozen=True) +class BoundLayout: + """Result of binding a Layout against a sink's reported zones. + + `matrix` and `strip` are tuples of BoundCell in render order. `unmapped` + holds zones the device reported that no Layout cell claimed; these get + appended to the strip with synthesized cells. + """ + + matrix: tuple[BoundCell, ...] = field(default_factory=tuple) + strip: tuple[BoundCell, ...] = field(default_factory=tuple) + unmapped: tuple[int, ...] = field(default_factory=tuple) diff --git a/lib/solaar/ui/perkey/layouts/__init__.py b/lib/solaar/ui/perkey/layouts/__init__.py new file mode 100644 index 00000000..48149386 --- /dev/null +++ b/lib/solaar/ui/perkey/layouts/__init__.py @@ -0,0 +1,87 @@ +## Copyright (C) 2024 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. + +"""Registry of per-key layouts, keyed by feature + a device-class match. + +Layouts register themselves with a matcher callable. `layout_for(feature, hint)` +returns the first matching layout, or None when no model-specific layout is +known — in which case the editor renders a flat strip of all reported zones. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from ..layout import Layout +from . import keyboard_ansi +from . import mouse_g502x + +# (feature_id, matcher, layout). Matcher receives a `hint` dict the editor +# assembles from the device (kind, wpid, codename, name, zones list, etc.). +_REGISTRY: list[tuple[int, Callable[[dict], bool], Layout]] = [] + + +def register_layout(feature: int, matcher: Callable[[dict], bool], layout: Layout) -> None: + _REGISTRY.append((feature, matcher, layout)) + + +def layout_for(feature: int, hint: dict) -> Layout | None: + for f, match, layout in _REGISTRY: + if f == feature and match(hint): + return layout + return None + + +def _name_contains(*needles: str) -> Callable[[dict], bool]: + """Build a matcher that returns True if any needle is a substring of the + device's name or codename (case-insensitive). Useful for device-family + layouts where multiple wpids share an LED arrangement. + """ + folded = tuple(n.upper() for n in needles) + + def match(hint: dict) -> bool: + for field in ("codename", "name"): + value = hint.get(field) + if not value: + continue + up = str(value).upper() + if any(n in up for n in folded): + return True + return False + + return match + + +# --- Keyboards: distinguish full-size from TKL by presence of a numpad zone. +# Counting zones is unreliable (G515 reports phantom zones 47, 97, 99-103, 254 +# that diverge from the keycap count). +def _has_numpad(hint: dict) -> bool: + zones = set(hint.get("zones", ())) + return 80 in zones or 95 in zones + + +def _is_full_keyboard(hint: dict) -> bool: + return hint.get("kind") == "keyboard" and _has_numpad(hint) + + +def _is_tkl_keyboard(hint: dict) -> bool: + return hint.get("kind") == "keyboard" and not _has_numpad(hint) + + +# PER_KEY_LIGHTING_V2 = 0x8081 +register_layout(0x8081, _is_full_keyboard, keyboard_ansi.LAYOUT_FULL) +register_layout(0x8081, _is_tkl_keyboard, keyboard_ansi.LAYOUT_TKL) +register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT) diff --git a/lib/solaar/ui/perkey/layouts/keyboard_ansi.py b/lib/solaar/ui/perkey/layouts/keyboard_ansi.py new file mode 100644 index 00000000..f24f30b6 --- /dev/null +++ b/lib/solaar/ui/perkey/layouts/keyboard_ansi.py @@ -0,0 +1,196 @@ +## Copyright (C) 2024 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. + +"""ANSI QWERTY keyboard layouts (full 104-key and TKL). + +Cell positions and groupings derived from OpenRGB's KeyboardLayoutManager +(KeyboardLayoutManager.cpp), Copyright (C) Chris M (Dr_No), licensed under +GPL-2.0-or-later. This file ports the static ANSI data only; the runtime +opcode interpreter for regional overlays is intentionally not included. + +Zone IDs are the firmware values reported by Logitech HID++ feature 0x8081 +(PER_KEY_LIGHTING_V2). +""" + +from __future__ import annotations + +from ..layout import Cell +from ..layout import Layout + +# Main alpha block (KLM keyboard_zone_main, ANSI variant). +# ANSI removes the ISO backslash (row 4 col 1) and POUND (row 3 col 12). +_MAIN: tuple[Cell, ...] = ( + # Row 1: backtick + numbers + minus/equals + backspace + Cell(zone_id=50, row=1, col=0, group="main", label="`"), + Cell(zone_id=27, row=1, col=1, group="main", label="1"), + Cell(zone_id=28, row=1, col=2, group="main", label="2"), + Cell(zone_id=29, row=1, col=3, group="main", label="3"), + Cell(zone_id=30, row=1, col=4, group="main", label="4"), + Cell(zone_id=31, row=1, col=5, group="main", label="5"), + Cell(zone_id=32, row=1, col=6, group="main", label="6"), + Cell(zone_id=33, row=1, col=7, group="main", label="7"), + Cell(zone_id=34, row=1, col=8, group="main", label="8"), + Cell(zone_id=35, row=1, col=9, group="main", label="9"), + Cell(zone_id=36, row=1, col=10, group="main", label="0"), + Cell(zone_id=42, row=1, col=11, group="main", label="-"), + Cell(zone_id=43, row=1, col=12, group="main", label="="), + Cell(zone_id=39, row=1, col=13, group="main", label="Bksp"), + # Row 2: tab + qwerty + brackets + backslash + Cell(zone_id=40, row=2, col=0, group="main", label="Tab"), + Cell(zone_id=17, row=2, col=1, group="main", label="Q"), + Cell(zone_id=23, row=2, col=2, group="main", label="W"), + Cell(zone_id=5, row=2, col=3, group="main", label="E"), + Cell(zone_id=18, row=2, col=4, group="main", label="R"), + Cell(zone_id=20, row=2, col=5, group="main", label="T"), + Cell(zone_id=25, row=2, col=6, group="main", label="Y"), + Cell(zone_id=21, row=2, col=7, group="main", label="U"), + Cell(zone_id=9, row=2, col=8, group="main", label="I"), + Cell(zone_id=15, row=2, col=9, group="main", label="O"), + Cell(zone_id=16, row=2, col=10, group="main", label="P"), + Cell(zone_id=44, row=2, col=11, group="main", label="["), + Cell(zone_id=45, row=2, col=12, group="main", label="]"), + Cell(zone_id=46, row=2, col=13, group="main", label="\\"), + # Row 3: caps + asdf-row + semi/quote + enter + Cell(zone_id=54, row=3, col=0, group="main", label="Caps"), + Cell(zone_id=1, row=3, col=1, group="main", label="A"), + Cell(zone_id=19, row=3, col=2, group="main", label="S"), + Cell(zone_id=4, row=3, col=3, group="main", label="D"), + Cell(zone_id=6, row=3, col=4, group="main", label="F"), + Cell(zone_id=7, row=3, col=5, group="main", label="G"), + Cell(zone_id=8, row=3, col=6, group="main", label="H"), + Cell(zone_id=10, row=3, col=7, group="main", label="J"), + Cell(zone_id=11, row=3, col=8, group="main", label="K"), + Cell(zone_id=12, row=3, col=9, group="main", label="L"), + Cell(zone_id=48, row=3, col=10, group="main", label=";"), + Cell(zone_id=49, row=3, col=11, group="main", label="'"), + Cell(zone_id=37, row=3, col=13, group="main", label="Enter"), + # Row 4: shift + zxcv-row + comma/period/slash + rshift + Cell(zone_id=105, row=4, col=0, group="main", label="Shift"), + Cell(zone_id=26, row=4, col=2, group="main", label="Z"), + Cell(zone_id=24, row=4, col=3, group="main", label="X"), + Cell(zone_id=3, row=4, col=4, group="main", label="C"), + Cell(zone_id=22, row=4, col=5, group="main", label="V"), + Cell(zone_id=2, row=4, col=6, group="main", label="B"), + Cell(zone_id=14, row=4, col=7, group="main", label="N"), + Cell(zone_id=13, row=4, col=8, group="main", label="M"), + Cell(zone_id=51, row=4, col=9, group="main", label=","), + Cell(zone_id=52, row=4, col=10, group="main", label="."), + Cell(zone_id=53, row=4, col=11, group="main", label="/"), + Cell(zone_id=109, row=4, col=13, group="main", label="Shift"), + # Row 5: bottom row. Space spans cols 3..9 visually. + Cell(zone_id=104, row=5, col=0, group="main", label="Ctrl"), + Cell(zone_id=107, row=5, col=1, group="main", label="Win"), + Cell(zone_id=106, row=5, col=2, group="main", label="Alt"), + Cell(zone_id=41, row=5, col=3, width=7.0, group="main", label="Space"), + Cell(zone_id=110, row=5, col=10, group="main", label="AltGr"), + Cell(zone_id=111, row=5, col=11, group="main", label="Win"), + Cell(zone_id=98, row=5, col=12, group="main", label="Menu"), + Cell(zone_id=108, row=5, col=13, group="main", label="Ctrl"), +) + +# Function row (KLM keyboard_zone_fn_row): ESC + F1..F12. +_FN_ROW: tuple[Cell, ...] = ( + Cell(zone_id=38, row=0, col=0, group="fn_row", label="Esc"), + Cell(zone_id=55, row=0, col=2, group="fn_row", label="F1"), + Cell(zone_id=56, row=0, col=3, group="fn_row", label="F2"), + Cell(zone_id=57, row=0, col=4, group="fn_row", label="F3"), + Cell(zone_id=58, row=0, col=5, group="fn_row", label="F4"), + Cell(zone_id=59, row=0, col=6, group="fn_row", label="F5"), + Cell(zone_id=60, row=0, col=7, group="fn_row", label="F6"), + Cell(zone_id=61, row=0, col=8, group="fn_row", label="F7"), + Cell(zone_id=62, row=0, col=9, group="fn_row", label="F8"), + Cell(zone_id=63, row=0, col=10, group="fn_row", label="F9"), + Cell(zone_id=64, row=0, col=11, group="fn_row", label="F10"), + Cell(zone_id=65, row=0, col=12, group="fn_row", label="F11"), + Cell(zone_id=66, row=0, col=13, group="fn_row", label="F12"), +) + +# Extras cluster (KLM keyboard_zone_extras): nav block + arrows. +_EXTRAS: tuple[Cell, ...] = ( + Cell(zone_id=67, row=0, col=14, group="extras", label="PrtSc"), + Cell(zone_id=68, row=0, col=15, group="extras", label="ScrLk"), + Cell(zone_id=69, row=0, col=16, group="extras", label="Pause"), + Cell(zone_id=70, row=1, col=14, group="extras", label="Ins"), + Cell(zone_id=71, row=1, col=15, group="extras", label="Home"), + Cell(zone_id=72, row=1, col=16, group="extras", label="PgUp"), + Cell(zone_id=73, row=2, col=14, group="extras", label="Del"), + Cell(zone_id=74, row=2, col=15, group="extras", label="End"), + Cell(zone_id=75, row=2, col=16, group="extras", label="PgDn"), + Cell(zone_id=79, row=4, col=15, group="extras", label="↑"), + Cell(zone_id=77, row=5, col=14, group="extras", label="←"), + Cell(zone_id=78, row=5, col=15, group="extras", label="↓"), + Cell(zone_id=76, row=5, col=16, group="extras", label="→"), +) + +# Numpad (KLM keyboard_zone_numpad). NumPad + and Enter span 2 rows tall. +_NUMPAD: tuple[Cell, ...] = ( + Cell(zone_id=80, row=1, col=17, group="numpad", label="Num"), + Cell(zone_id=81, row=1, col=18, group="numpad", label="/"), + Cell(zone_id=82, row=1, col=19, group="numpad", label="*"), + Cell(zone_id=83, row=1, col=20, group="numpad", label="-"), + Cell(zone_id=92, row=2, col=17, group="numpad", label="7"), + Cell(zone_id=93, row=2, col=18, group="numpad", label="8"), + Cell(zone_id=94, row=2, col=19, group="numpad", label="9"), + Cell(zone_id=84, row=2, col=20, height=2.0, group="numpad", label="+"), + Cell(zone_id=89, row=3, col=17, group="numpad", label="4"), + Cell(zone_id=90, row=3, col=18, group="numpad", label="5"), + Cell(zone_id=91, row=3, col=19, group="numpad", label="6"), + Cell(zone_id=86, row=4, col=17, group="numpad", label="1"), + Cell(zone_id=87, row=4, col=18, group="numpad", label="2"), + Cell(zone_id=88, row=4, col=19, group="numpad", label="3"), + Cell(zone_id=85, row=4, col=20, height=2.0, group="numpad", label="Enter"), + Cell(zone_id=95, row=5, col=17, width=2.0, group="numpad", label="0"), + Cell(zone_id=96, row=5, col=19, group="numpad", label="."), +) + + +# Curated allowlist for unmapped device zones surfaced in the bottom strip. +# Mirrors OpenRGB's `hidpp20_key_name_to_zone` extras: brightness, media, +# G1-G5, logo. Anything else (e.g. G515 phantoms 47, 97, 99-103, 254) is +# dropped by the binder. +_EXTRAS_ALLOWLIST: frozenset[int] = frozenset( + { + 153, # Brightness + 155, # Play/Pause + 156, # Mute + 157, # Next + 158, # Previous + 180, # G1 + 181, # G2 + 182, # G3 + 183, # G4 + 184, # G5 + 210, # Logo + } +) + + +LAYOUT_FULL: Layout = Layout( + cells=_FN_ROW + _MAIN + _EXTRAS + _NUMPAD, + rows=6, + cols=21, + extra_zones=_EXTRAS_ALLOWLIST, + description="ANSI QWERTY 104-key full-size", +) + + +LAYOUT_TKL: Layout = Layout( + cells=_FN_ROW + _MAIN + _EXTRAS, + rows=6, + cols=17, + extra_zones=_EXTRAS_ALLOWLIST, + description="ANSI QWERTY tenkeyless", +) diff --git a/lib/solaar/ui/perkey/layouts/mouse_g502x.py b/lib/solaar/ui/perkey/layouts/mouse_g502x.py new file mode 100644 index 00000000..2d0f4b52 --- /dev/null +++ b/lib/solaar/ui/perkey/layouts/mouse_g502x.py @@ -0,0 +1,49 @@ +## Copyright (C) 2024 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. + +"""LED layout for the G502 X family (G502 X, G502 X PLUS, G502 X LIGHTSPEED). + +Eight LEDs (A..H) reported as zones 1..8 by the firmware. Positions may +need revision per actual hardware. + + Row 0: C . . . . . B + Row 1: . D H G F E . + Row 2: . . . . . . A +""" + +from __future__ import annotations + +from ..layout import Cell +from ..layout import Layout + +_CELLS: tuple[Cell, ...] = ( + Cell(zone_id=1, row=2, col=6, group="main", label="A"), + Cell(zone_id=2, row=0, col=6, group="main", label="B"), + Cell(zone_id=3, row=0, col=0, group="main", label="C"), + Cell(zone_id=4, row=1, col=1, group="main", label="D"), + Cell(zone_id=5, row=1, col=5, group="main", label="E"), + Cell(zone_id=6, row=1, col=4, group="main", label="F"), + Cell(zone_id=7, row=1, col=3, group="main", label="G"), + Cell(zone_id=8, row=1, col=2, group="main", label="H"), +) + + +LAYOUT: Layout = Layout( + cells=_CELLS, + rows=3, + cols=7, + description="Logitech G502 X family", +) diff --git a/lib/solaar/ui/perkey/palette.py b/lib/solaar/ui/perkey/palette.py new file mode 100644 index 00000000..a35c8178 --- /dev/null +++ b/lib/solaar/ui/perkey/palette.py @@ -0,0 +1,255 @@ +## Copyright (C) 2024 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 + + +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 + + +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 + + +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_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_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) + + 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) + + 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) + + def _on_draw(self, _w, cr) -> None: + import cairo # local: keeps the module light when GradientSwatch isn't built + + s = self.SIZE + + 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) + + # Top-left (previous, gradient start) → bottom-right (active, end). + # Matches the directional behavior of dragging the line tool TL → BR. + pat = cairo.LinearGradient(0, 0, s, s) + 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(0, 0, s, s) + cr.fill() + + # Subtle border so the swatch reads as a control even on similar bg. + cr.set_source_rgba(0, 0, 0, 0.45) + cr.set_line_width(1.0) + cr.rectangle(0.5, 0.5, s - 1, s - 1) + cr.stroke() diff --git a/lib/solaar/ui/perkey/protocol.py b/lib/solaar/ui/perkey/protocol.py new file mode 100644 index 00000000..0feb26bf --- /dev/null +++ b/lib/solaar/ui/perkey/protocol.py @@ -0,0 +1,73 @@ +## Copyright (C) 2024 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. + +"""Narrow contract between the per-key editor and any color-map setting. + +The editor consumes only this protocol, never `lib.logitech_receiver` directly. +This is the seam where a future frontend/backend split would cut cleanly. +""" + +from __future__ import annotations + +from typing import Callable +from typing import Protocol + + +class PerKeyColorSink(Protocol): + """A device's per-key color buffer, exposed without device internals. + + Colors are 24-bit packed RGB ints (0xRRGGBB). The sentinel value -1 means + "no change" / "unset" (matches `special_keys.COLORSPLUS["No change"]`). + """ + + @property + def title(self) -> str: + ... + + @property + def zones(self) -> list[int]: + ... + + @property + def current(self) -> dict[int, int]: + ... + + def label(self, zone: int) -> str: + ... + + def write_one(self, zone: int, color: int) -> None: + ... + + def write_bulk(self, deltas: dict[int, int]) -> None: + ... + + def subscribe(self, listener: Callable[[dict[int, int]], None]) -> Callable[[], None]: + """Register a callback for current-value changes; return an unsubscribe handle.""" + ... + + def palette_state(self) -> tuple[int, int] | None: + """Return the persisted (active_color, previous_color) for this device's palette, or None.""" + ... + + def set_palette_state(self, active: int, previous: int) -> None: + """Persist the palette's active and previous colors for this device.""" + ... + + def zone_base_color(self) -> int | None: + """Return the zone base color (what 'no change' cells actually display + on the keyboard), or None if the device has no zone effect. + """ + ... diff --git a/lib/solaar/ui/perkey/tools.py b/lib/solaar/ui/perkey/tools.py new file mode 100644 index 00000000..98f4482f --- /dev/null +++ b/lib/solaar/ui/perkey/tools.py @@ -0,0 +1,223 @@ +## Copyright (C) 2024 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. + +"""Paint tools for the per-key editor. + +Each tool is a stateless policy object. The Canvas owns per-stroke state +(press cell, motion cell, brush path) and asks the active tool for the +final delta on release. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from typing import Protocol + +from .layout import BoundCell +from .layout import Cell + + +@dataclass +class ToolContext: + active_color: int + last_color: int + cells_by_zone: dict[int, BoundCell] + # zone ids that live in the bottom strip (e.g. logo, G-keys); kept separate + # because their on-screen position is decoupled from the matrix grid. + strip_zones: frozenset = frozenset() + # zone_id -> current packed RGB (or -1 sentinel for unset). Used by tools + # that need to compare colors, like the flood-fill bucket. + current_colors: dict = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.current_colors is None: + self.current_colors = {} + + def bound_cells(self) -> list[BoundCell]: + return list(self.cells_by_zone.values()) + + def matrix_cells(self) -> list[BoundCell]: + cells = [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.zone_id not in self.strip_zones] + if cells: + return cells + # No matrix region (e.g. a mouse, where every zone lives in the + # strip). Fall back to all bound cells so directional tools still + # have something to project across. + return [bc for bc in self.cells_by_zone.values() if bc.bound] + + def cells_in_bbox(self, a: BoundCell, b: BoundCell) -> list[BoundCell]: + cx_a, cy_a = _cell_center(a.cell) + cx_b, cy_b = _cell_center(b.cell) + x0, x1 = (cx_a, cx_b) if cx_a <= cx_b else (cx_b, cx_a) + y0, y1 = (cy_a, cy_b) if cy_a <= cy_b else (cy_b, cy_a) + result = [] + for bc in self.cells_by_zone.values(): + if not bc.bound: + continue + cx, cy = _cell_center(bc.cell) + if x0 <= cx <= x1 and y0 <= cy <= y1: + result.append(bc) + return result + + def cells_in_bbox_ordered(self, a: BoundCell, b: BoundCell) -> list[BoundCell]: + cells = self.cells_in_bbox(a, b) + return sorted(cells, key=lambda c: (c.cell.row, c.cell.col)) + + def cells_in_group(self, group: str) -> list[BoundCell]: + return [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.group == group] + + +def _cell_center(cell: Cell) -> tuple[float, float]: + return (cell.col + cell.width / 2.0, cell.row + cell.height / 2.0) + + +def _cells_touch(a: Cell, b: Cell) -> bool: + """Bounding-box edge adjacency in grid units. Handles variable widths + (Space spans multiple cols, Numpad+ spans multiple rows). + """ + a_c1, a_r1 = a.col + a.width, a.row + a.height + b_c1, b_r1 = b.col + b.width, b.row + b.height + rows_overlap = a.row < b_r1 and b.row < a_r1 + cols_overlap = a.col < b_c1 and b.col < a_c1 + if (a_c1 == b.col or b_c1 == a.col) and rows_overlap: + return True + if (a_r1 == b.row or b_r1 == a.row) and cols_overlap: + return True + return False + + +class Tool(Protocol): + name: str + is_brush: bool + overlay_shape: str # "" | "rect" + + def compute( + self, + start: BoundCell | None, + end: BoundCell | None, + path: Iterable[int], + ctx: ToolContext, + ) -> dict[int, int]: + ... + + +class SingleTool: + name = "single" + is_brush = True + overlay_shape = "" + + def compute(self, start, end, path, ctx): + return {z: ctx.active_color for z in path} + + +class RectTool: + name = "rect" + is_brush = False + overlay_shape = "rect" + + def compute(self, start, end, path, ctx): + if start is None or end is None: + return {} + return {bc.cell.zone_id: ctx.active_color for bc in ctx.cells_in_bbox(start, end)} + + +class BucketTool: + """Flood-fill: replace the clicked cell's color in every connected cell + of the same color (4-adjacent on the matrix grid). Strip cells aren't on + the matrix grid, so clicking one paints just that cell. + """ + + name = "bucket" + is_brush = False + overlay_shape = "" + + def compute(self, start, end, path, ctx): + if start is None: + return {} + new_color = ctx.active_color + target = ctx.current_colors.get(start.cell.zone_id, -1) + if target == new_color: + return {} + if start.cell.zone_id in ctx.strip_zones: + return {start.cell.zone_id: new_color} + # BFS over matrix cells + cells_by_id = {z: bc for z, bc in ctx.cells_by_zone.items() if bc.bound and z not in ctx.strip_zones} + visited = {start.cell.zone_id} + stack = [start] + result: dict[int, int] = {} + while stack: + bc = stack.pop() + result[bc.cell.zone_id] = new_color + for other in cells_by_id.values(): + if other.cell.zone_id in visited: + continue + if ctx.current_colors.get(other.cell.zone_id, -1) != target: + continue + if not _cells_touch(bc.cell, other.cell): + continue + visited.add(other.cell.zone_id) + stack.append(other) + return result + + +class GradientTool: + """Directional gradient: drag a line from A to B, the whole matrix gets a + gradient at that angle. Cells projecting before A clamp to the previous + color; cells past B clamp to the active color. + """ + + name = "gradient" + is_brush = False + overlay_shape = "line" + + def compute(self, start, end, path, ctx): + if start is None or end is None: + return {} + ax, ay = _cell_center(start.cell) + bx, by = _cell_center(end.cell) + vx, vy = bx - ax, by - ay + length_sq = vx * vx + vy * vy + if length_sq == 0: + return {start.cell.zone_id: ctx.active_color} + result: dict[int, int] = {} + for bc in ctx.matrix_cells(): + cx, cy = _cell_center(bc.cell) + t = ((cx - ax) * vx + (cy - ay) * vy) / length_sq + t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t + result[bc.cell.zone_id] = _lerp_rgb(ctx.last_color, ctx.active_color, t) + return result + + +def _lerp_rgb(c0: int, c1: int, t: float) -> int: + if c0 < 0: + c0 = c1 + if c1 < 0: + c1 = c0 + r0, g0, b0 = (c0 >> 16) & 0xFF, (c0 >> 8) & 0xFF, c0 & 0xFF + r1, g1, b1 = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF + r = int(round(r0 + (r1 - r0) * t)) + g = int(round(g0 + (g1 - g0) * t)) + b = int(round(b0 + (b1 - b0) * t)) + return (r << 16) | (g << 8) | b + + +TOOLS: dict[str, Tool] = { + "single": SingleTool(), + "rect": RectTool(), + "bucket": BucketTool(), + "gradient": GradientTool(), +} diff --git a/setup.py b/setup.py index 4f0405e4..41506287 100755 --- a/setup.py +++ b/setup.py @@ -76,6 +76,7 @@ setup( "psutil (>= 5.4.3)", 'dbus-python ; platform_system=="Linux"', "PyGObject", + "pycairo", "typing_extensions", ], extras_require={ diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index dbfb9b05..69793819 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -27,10 +27,14 @@ from logitech_receiver import common from logitech_receiver import hidpp20 from logitech_receiver import hidpp20_constants from logitech_receiver import settings_templates +from logitech_receiver import settings_validator from logitech_receiver import special_keys from . import fake_hidpp +# Per-key colors: any 24-bit RGB; matches PerKeyLighting.validator_class._COLOR_RANGE. +_PERKEY_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3) + # TODO action part of DpiSlidingXY, MouseGesturesXY @@ -695,11 +699,11 @@ key_tests = [ Setup( FeatureTest(settings_templates.PerKeyLighting, {1: -1, 2: -1, 9: -1, 10: -1, 113: -1}, {2: 0xFF0000}, 4, 4, 0, 1), { - common.NamedInt(1, "A"): special_keys.COLORSPLUS, - common.NamedInt(2, "B"): special_keys.COLORSPLUS, - common.NamedInt(9, "I"): special_keys.COLORSPLUS, - common.NamedInt(10, "J"): special_keys.COLORSPLUS, - common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, + common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE, + common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE, + common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE, + common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE, + common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE, }, fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys @@ -720,11 +724,11 @@ key_tests = [ 1, ), { - common.NamedInt(1, "A"): special_keys.COLORSPLUS, - common.NamedInt(2, "B"): special_keys.COLORSPLUS, - common.NamedInt(9, "I"): special_keys.COLORSPLUS, - common.NamedInt(10, "J"): special_keys.COLORSPLUS, - common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, + common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE, + common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE, + common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE, + common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE, + common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE, }, fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys @@ -749,12 +753,12 @@ key_tests = [ 1, ), { - common.NamedInt(1, "A"): special_keys.COLORSPLUS, - common.NamedInt(2, "B"): special_keys.COLORSPLUS, - common.NamedInt(9, "I"): special_keys.COLORSPLUS, - common.NamedInt(10, "J"): special_keys.COLORSPLUS, - common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, - common.NamedInt(114, "KEY 114"): special_keys.COLORSPLUS, + common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE, + common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE, + common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE, + common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE, + common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE, + common.NamedInt(114, "KEY 114"): _PERKEY_COLOR_RANGE, }, fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000600000000000000000000000000", 0x0400, "0001"), # second group of keys