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 <dev> 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.
This commit is contained in:
Ken Sanislo 2026-05-10 01:43:19 -07:00 committed by Peter F. Patel-Schneider
parent 4f3583ae10
commit d8422d78d1
21 changed files with 2182 additions and 28 deletions

View File

@ -61,6 +61,10 @@ class Setting:
validator_class = None validator_class = None
validator_options = {} validator_options = {}
display = True # display setting in UI 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): def __init__(self, device, rw, validator):
self._device = device self._device = device

View File

@ -1893,7 +1893,7 @@ class PerKeyLighting(settings.Settings):
description = _("Control per-key lighting.") description = _("Control per-key lighting.")
feature = _F.PER_KEY_LIGHTING_V2 feature = _F.PER_KEY_LIGHTING_V2
keys_universe = special_keys.KEYCODES keys_universe = special_keys.KEYCODES
choices_universe = special_keys.COLORSPLUS editor_class = "solaar.ui.perkey.control:PerKeyControl"
def read(self, cached=True): def read(self, cached=True):
self._pre_read(cached) self._pre_read(cached)
@ -1950,7 +1950,9 @@ class PerKeyLighting(settings.Settings):
class rw_class(settings.FeatureRWMap): class rw_class(settings.FeatureRWMap):
pass 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 @classmethod
def build(cls, setting_class, device): def build(cls, setting_class, device):
choices_map = {} choices_map = {}
@ -1964,9 +1966,8 @@ class PerKeyLighting(settings.Settings):
if i in setting_class.keys_universe if i in setting_class.keys_universe
else common.NamedInt(i, f"KEY {str(i)}") else common.NamedInt(i, f"KEY {str(i)}")
) )
choices_map[key] = setting_class.choices_universe choices_map[key] = cls._COLOR_RANGE
result = cls(choices_map) if choices_map else None return cls(choices_map) if choices_map else None
return result
# Allow changes to force sensing buttons # Allow changes to force sensing buttons

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import math import math
from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from logitech_receiver import common from logitech_receiver import common
@ -746,3 +747,91 @@ class MultipleRangeValidator(Validator):
def compare(self, args, current): def compare(self, args, current):
logger.warning("compare not implemented for multiple range settings") logger.warning("compare not implemented for multiple range settings")
return False 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]

View File

@ -20,12 +20,28 @@ from logitech_receiver import settings
from logitech_receiver import settings_templates from logitech_receiver import settings_templates
from logitech_receiver.common import NamedInts from logitech_receiver.common import NamedInts
from logitech_receiver.settings_templates import SettingsProtocol from logitech_receiver.settings_templates import SettingsProtocol
from logitech_receiver.settings_validator import Range
from solaar import configuration from solaar import configuration
APP_ID = "io.github.pwr_solaar.solaar" 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): def _print_setting(s, verbose=True):
print("#", s.label) print("#", s.label)
if verbose: if verbose:
@ -70,7 +86,11 @@ def _print_setting_keyed(s, key, verbose=True):
if k is None: if k is None:
print(s.name, "=? (key not found)") print(s.name, "=? (key not found)")
else: 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) value = s.read(cached=False)
if value is None: if value is None:
print(s.name, "= ? (failed to read from device)") 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) k = next((k for k in setting.choices.keys() if key == k), None)
if k is None and ikey is not None: if k is None and ikey is not None:
k = next((k for k in setting.choices.keys() if ikey == k), None) k = next((k for k in setting.choices.keys() if ikey == k), None)
if k is not None: if k is None:
value = select_choice(args.extra_subkey, setting.choices[k], setting, key)
args.extra_subkey = int(value)
args.value_key = str(int(k))
else:
raise Exception(f"{setting.name}: key '{key}' not in setting") 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}" message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
result = setting.write_key_value(int(k), value, save=save) result = setting.write_key_value(int(k), value, save=save)

View File

@ -764,7 +764,24 @@ def _create_sbox(s, _device):
change.set_sensitive(True) change.set_sensitive(True)
change.connect(GtkSignal.CLICKED.value, _change_click, sbox) 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) control = ToggleControl(sbox)
elif s.kind == settings.Kind.RANGE: elif s.kind == settings.Kind.RANGE:
control = SliderControl(sbox) control = SliderControl(sbox)

View File

@ -42,6 +42,7 @@ from logitech_receiver.common import UnsortedNamedInts
from logitech_receiver.settings import Kind from logitech_receiver.settings import Kind
from logitech_receiver.settings import Setting from logitech_receiver.settings import Setting
from logitech_receiver.settings_templates import SETTINGS from logitech_receiver.settings_templates import SETTINGS
from logitech_receiver.settings_validator import Range
from solaar.i18n import _ from solaar.i18n import _
from solaar.ui import rule_actions from solaar.ui import rule_actions
@ -1675,6 +1676,15 @@ class _SettingWithValueUI:
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE): if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
self.value_field.make_toggle() self.value_field.make_toggle()
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE): 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) all_values, extra = self._all_choices(device_setting or setting_name)
self.value_field.make_choice(all_values, extra) self.value_field.make_choice(all_values, extra)
supported_values = None supported_values = None

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()})

View File

@ -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)

View File

@ -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)

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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()

View File

@ -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.
"""
...

View File

@ -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(),
}

View File

@ -76,6 +76,7 @@ setup(
"psutil (>= 5.4.3)", "psutil (>= 5.4.3)",
'dbus-python ; platform_system=="Linux"', 'dbus-python ; platform_system=="Linux"',
"PyGObject", "PyGObject",
"pycairo",
"typing_extensions", "typing_extensions",
], ],
extras_require={ extras_require={

View File

@ -27,10 +27,14 @@ from logitech_receiver import common
from logitech_receiver import hidpp20 from logitech_receiver import hidpp20
from logitech_receiver import hidpp20_constants from logitech_receiver import hidpp20_constants
from logitech_receiver import settings_templates from logitech_receiver import settings_templates
from logitech_receiver import settings_validator
from logitech_receiver import special_keys from logitech_receiver import special_keys
from . import fake_hidpp 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 # TODO action part of DpiSlidingXY, MouseGesturesXY
@ -695,11 +699,11 @@ key_tests = [
Setup( Setup(
FeatureTest(settings_templates.PerKeyLighting, {1: -1, 2: -1, 9: -1, 10: -1, 113: -1}, {2: 0xFF0000}, 4, 4, 0, 1), 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(1, "A"): _PERKEY_COLOR_RANGE,
common.NamedInt(2, "B"): special_keys.COLORSPLUS, common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
common.NamedInt(9, "I"): special_keys.COLORSPLUS, common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
common.NamedInt(10, "J"): special_keys.COLORSPLUS, common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
}, },
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys
@ -720,11 +724,11 @@ key_tests = [
1, 1,
), ),
{ {
common.NamedInt(1, "A"): special_keys.COLORSPLUS, common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE,
common.NamedInt(2, "B"): special_keys.COLORSPLUS, common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
common.NamedInt(9, "I"): special_keys.COLORSPLUS, common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
common.NamedInt(10, "J"): special_keys.COLORSPLUS, common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
}, },
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys
@ -749,12 +753,12 @@ key_tests = [
1, 1,
), ),
{ {
common.NamedInt(1, "A"): special_keys.COLORSPLUS, common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE,
common.NamedInt(2, "B"): special_keys.COLORSPLUS, common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
common.NamedInt(9, "I"): special_keys.COLORSPLUS, common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
common.NamedInt(10, "J"): special_keys.COLORSPLUS, common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS, common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
common.NamedInt(114, "KEY 114"): special_keys.COLORSPLUS, common.NamedInt(114, "KEY 114"): _PERKEY_COLOR_RANGE,
}, },
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
fake_hidpp.Response("00000600000000000000000000000000", 0x0400, "0001"), # second group of keys fake_hidpp.Response("00000600000000000000000000000000", 0x0400, "0001"), # second group of keys