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:
parent
4f3583ae10
commit
d8422d78d1
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()})
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
1
setup.py
1
setup.py
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue