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_options = {}
|
||||
display = True # display setting in UI
|
||||
# Optional UI editor override as "module.path:ClassName". Resolved by the
|
||||
# config panel before the Kind dispatch. Kept as a string so this module
|
||||
# stays free of GTK imports — the FE/BE seam is preserved.
|
||||
editor_class: str | None = None
|
||||
|
||||
def __init__(self, device, rw, validator):
|
||||
self._device = device
|
||||
|
|
|
|||
|
|
@ -1893,7 +1893,7 @@ class PerKeyLighting(settings.Settings):
|
|||
description = _("Control per-key lighting.")
|
||||
feature = _F.PER_KEY_LIGHTING_V2
|
||||
keys_universe = special_keys.KEYCODES
|
||||
choices_universe = special_keys.COLORSPLUS
|
||||
editor_class = "solaar.ui.perkey.control:PerKeyControl"
|
||||
|
||||
def read(self, cached=True):
|
||||
self._pre_read(cached)
|
||||
|
|
@ -1950,7 +1950,9 @@ class PerKeyLighting(settings.Settings):
|
|||
class rw_class(settings.FeatureRWMap):
|
||||
pass
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings_validator.MapRangeValidator):
|
||||
_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3)
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
choices_map = {}
|
||||
|
|
@ -1964,9 +1966,8 @@ class PerKeyLighting(settings.Settings):
|
|||
if i in setting_class.keys_universe
|
||||
else common.NamedInt(i, f"KEY {str(i)}")
|
||||
)
|
||||
choices_map[key] = setting_class.choices_universe
|
||||
result = cls(choices_map) if choices_map else None
|
||||
return result
|
||||
choices_map[key] = cls._COLOR_RANGE
|
||||
return cls(choices_map) if choices_map else None
|
||||
|
||||
|
||||
# Allow changes to force sensing buttons
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import math
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from logitech_receiver import common
|
||||
|
|
@ -746,3 +747,91 @@ class MultipleRangeValidator(Validator):
|
|||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for multiple range settings")
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Range:
|
||||
"""Inclusive integer range used as the value side of a MapRangeValidator.
|
||||
|
||||
`byte_count` is the wire encoding width. `signed` selects two's-complement.
|
||||
Settings whose value space is a continuous integer range (e.g. per-key RGB
|
||||
colors as 24-bit ints) use this in place of a NamedInts choice list.
|
||||
"""
|
||||
|
||||
min: int
|
||||
max: int
|
||||
byte_count: int = 1
|
||||
signed: bool = False
|
||||
|
||||
def contains(self, value: int) -> bool:
|
||||
return isinstance(value, int) and self.min <= value <= self.max
|
||||
|
||||
|
||||
class MapRangeValidator(Validator):
|
||||
"""Map of keys → integer in a per-key Range. Open value space (no choice list).
|
||||
|
||||
Reports `kind = Kind.MAP_CHOICE` so the existing config-panel/CLI/rule-engine
|
||||
dispatch keeps routing without new branches; consumers that need to tell
|
||||
"choice list" from "open range" check `isinstance(setting.choices[k], Range)`.
|
||||
|
||||
TODO: complete `Kind.MAP_RANGE` infrastructure (UI dispatch, generic rule-UI
|
||||
handling, generalize `cli/config.py:299`) and migrate this validator's
|
||||
`kind` over. Today MAP_RANGE is only honored by `ForceSensing` via the
|
||||
`settings_new` framework; bridging both frameworks is a separate task.
|
||||
"""
|
||||
|
||||
kind = Kind.MAP_CHOICE
|
||||
|
||||
def __init__(self, choices_map, key_byte_count=1, write_prefix_bytes=b""):
|
||||
assert isinstance(choices_map, dict)
|
||||
for k, v in choices_map.items():
|
||||
assert isinstance(k, NamedInt), f"MapRangeValidator key must be NamedInt, got {type(k).__name__}"
|
||||
assert isinstance(v, Range), f"MapRangeValidator value must be Range, got {type(v).__name__}"
|
||||
self.choices = choices_map
|
||||
self.needs_current_value = False
|
||||
self._key_byte_count = key_byte_count
|
||||
self._write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def to_string(self, value) -> str:
|
||||
if not isinstance(value, dict):
|
||||
return str(value)
|
||||
return "{" + ", ".join(f"{k}:{value[k]}" for k in sorted(value)) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes, key):
|
||||
rng = self.choices.get(key)
|
||||
if rng is None:
|
||||
return None
|
||||
end = self._key_byte_count + rng.byte_count
|
||||
return common.bytes2int(reply_bytes[self._key_byte_count : end], signed=rng.signed)
|
||||
|
||||
def prepare_key(self, key):
|
||||
return int(key).to_bytes(self._key_byte_count, "big")
|
||||
|
||||
def prepare_write(self, key, new_value):
|
||||
rng = self.choices.get(key)
|
||||
if rng is None:
|
||||
logger.error("invalid key %r for map-range setting", key)
|
||||
return None
|
||||
if not rng.contains(new_value):
|
||||
logger.error("value %r out of range [%d, %d] for key %s", new_value, rng.min, rng.max, key)
|
||||
return None
|
||||
return self._write_prefix_bytes + int(new_value).to_bytes(rng.byte_count, "big", signed=rng.signed)
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if not isinstance(args, list) or len(args) != 2:
|
||||
return None
|
||||
key = next((k for k in self.choices if int(k) == int(args[0])), None)
|
||||
if key is None:
|
||||
return None
|
||||
rng = self.choices[key]
|
||||
if not rng.contains(args[1]):
|
||||
return None
|
||||
return [int(key), int(args[1])]
|
||||
|
||||
def compare(self, args, current):
|
||||
if not isinstance(args, list) or len(args) != 2 or not isinstance(current, dict):
|
||||
return False
|
||||
key = next((k for k in self.choices if int(k) == int(args[0])), None)
|
||||
if key is None:
|
||||
return False
|
||||
return current.get(int(key)) == args[1]
|
||||
|
|
|
|||
|
|
@ -20,12 +20,28 @@ from logitech_receiver import settings
|
|||
from logitech_receiver import settings_templates
|
||||
from logitech_receiver.common import NamedInts
|
||||
from logitech_receiver.settings_templates import SettingsProtocol
|
||||
from logitech_receiver.settings_validator import Range
|
||||
|
||||
from solaar import configuration
|
||||
|
||||
APP_ID = "io.github.pwr_solaar.solaar"
|
||||
|
||||
|
||||
def _parse_int_or_hex(s) -> int | None:
|
||||
"""Parse 0xRRGGBB / #RRGGBB / decimal int. Returns None on bad input."""
|
||||
if not isinstance(s, str):
|
||||
return None
|
||||
s = s.strip()
|
||||
try:
|
||||
if s.startswith("#"):
|
||||
return int(s[1:], 16)
|
||||
if s.lower().startswith("0x"):
|
||||
return int(s, 16)
|
||||
return int(s, 10)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _print_setting(s, verbose=True):
|
||||
print("#", s.label)
|
||||
if verbose:
|
||||
|
|
@ -70,7 +86,11 @@ def _print_setting_keyed(s, key, verbose=True):
|
|||
if k is None:
|
||||
print(s.name, "=? (key not found)")
|
||||
else:
|
||||
print("# possible values: one of [", ", ".join(str(v) for v in s.choices[k]), "]")
|
||||
value_space = s.choices[k]
|
||||
if isinstance(value_space, Range):
|
||||
print(f"# possible values: integer in [{value_space.min}, {value_space.max}] (decimal or 0xHEX)")
|
||||
else:
|
||||
print("# possible values: one of [", ", ".join(str(v) for v in value_space), "]")
|
||||
value = s.read(cached=False)
|
||||
if value is None:
|
||||
print(s.name, "= ? (failed to read from device)")
|
||||
|
|
@ -245,12 +265,21 @@ def set(dev, setting: SettingsProtocol, args, save):
|
|||
k = next((k for k in setting.choices.keys() if key == k), None)
|
||||
if k is None and ikey is not None:
|
||||
k = next((k for k in setting.choices.keys() if ikey == k), None)
|
||||
if k is not None:
|
||||
value = select_choice(args.extra_subkey, setting.choices[k], setting, key)
|
||||
args.extra_subkey = int(value)
|
||||
args.value_key = str(int(k))
|
||||
else:
|
||||
if k is None:
|
||||
raise Exception(f"{setting.name}: key '{key}' not in setting")
|
||||
value_space = setting.choices[k]
|
||||
if isinstance(value_space, Range):
|
||||
ivalue = _parse_int_or_hex(args.extra_subkey)
|
||||
if ivalue is None or not value_space.contains(ivalue):
|
||||
raise Exception(
|
||||
f"{setting.name}: value '{args.extra_subkey}' must be an integer in "
|
||||
f"[{value_space.min}, {value_space.max}] (decimal or 0xHEX / #HEX)"
|
||||
)
|
||||
value = ivalue
|
||||
else:
|
||||
value = select_choice(args.extra_subkey, value_space, setting, key)
|
||||
args.extra_subkey = int(value)
|
||||
args.value_key = str(int(k))
|
||||
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
|
||||
result = setting.write_key_value(int(k), value, save=save)
|
||||
|
||||
|
|
|
|||
|
|
@ -764,7 +764,24 @@ def _create_sbox(s, _device):
|
|||
change.set_sensitive(True)
|
||||
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
|
||||
|
||||
if s.kind == settings.Kind.TOGGLE:
|
||||
editor_path = getattr(s, "editor_class", None)
|
||||
if editor_path:
|
||||
try:
|
||||
mod_name, _sep, cls_name = editor_path.partition(":")
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module(mod_name)
|
||||
cls = getattr(mod, cls_name)
|
||||
control = cls(sbox)
|
||||
except Exception as e:
|
||||
logger.warning("setting %s editor_class %r failed (%s); falling back to default", s.name, editor_path, repr(e))
|
||||
control = None
|
||||
else:
|
||||
control = None
|
||||
|
||||
if control is not None:
|
||||
pass
|
||||
elif s.kind == settings.Kind.TOGGLE:
|
||||
control = ToggleControl(sbox)
|
||||
elif s.kind == settings.Kind.RANGE:
|
||||
control = SliderControl(sbox)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from logitech_receiver.common import UnsortedNamedInts
|
|||
from logitech_receiver.settings import Kind
|
||||
from logitech_receiver.settings import Setting
|
||||
from logitech_receiver.settings_templates import SETTINGS
|
||||
from logitech_receiver.settings_validator import Range
|
||||
|
||||
from solaar.i18n import _
|
||||
from solaar.ui import rule_actions
|
||||
|
|
@ -1675,6 +1676,15 @@ class _SettingWithValueUI:
|
|||
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
|
||||
self.value_field.make_toggle()
|
||||
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE):
|
||||
# Open-value-space MAP_CHOICE settings (per-key RGB) have a Range
|
||||
# rather than a NamedInts value list — there's no meaningful value
|
||||
# picker to render in the rule editor, so fall through to unsupported.
|
||||
if kind == Kind.MAP_CHOICE and device_setting:
|
||||
val = device_setting._validator
|
||||
choices = getattr(val, "choices", None)
|
||||
if isinstance(choices, dict) and any(isinstance(v, Range) for v in choices.values()):
|
||||
self.value_field.make_unsupported()
|
||||
return
|
||||
all_values, extra = self._all_choices(device_setting or setting_name)
|
||||
self.value_field.make_choice(all_values, extra)
|
||||
supported_values = None
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
'dbus-python ; platform_system=="Linux"',
|
||||
"PyGObject",
|
||||
"pycairo",
|
||||
"typing_extensions",
|
||||
],
|
||||
extras_require={
|
||||
|
|
|
|||
|
|
@ -27,10 +27,14 @@ from logitech_receiver import common
|
|||
from logitech_receiver import hidpp20
|
||||
from logitech_receiver import hidpp20_constants
|
||||
from logitech_receiver import settings_templates
|
||||
from logitech_receiver import settings_validator
|
||||
from logitech_receiver import special_keys
|
||||
|
||||
from . import fake_hidpp
|
||||
|
||||
# Per-key colors: any 24-bit RGB; matches PerKeyLighting.validator_class._COLOR_RANGE.
|
||||
_PERKEY_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3)
|
||||
|
||||
# TODO action part of DpiSlidingXY, MouseGesturesXY
|
||||
|
||||
|
||||
|
|
@ -695,11 +699,11 @@ key_tests = [
|
|||
Setup(
|
||||
FeatureTest(settings_templates.PerKeyLighting, {1: -1, 2: -1, 9: -1, 10: -1, 113: -1}, {2: 0xFF0000}, 4, 4, 0, 1),
|
||||
{
|
||||
common.NamedInt(1, "A"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(2, "B"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(9, "I"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(10, "J"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
|
||||
},
|
||||
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
|
||||
fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys
|
||||
|
|
@ -720,11 +724,11 @@ key_tests = [
|
|||
1,
|
||||
),
|
||||
{
|
||||
common.NamedInt(1, "A"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(2, "B"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(9, "I"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(10, "J"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
|
||||
},
|
||||
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
|
||||
fake_hidpp.Response("00000200000000000000000000000000", 0x0400, "0001"), # second group of keys
|
||||
|
|
@ -749,12 +753,12 @@ key_tests = [
|
|||
1,
|
||||
),
|
||||
{
|
||||
common.NamedInt(1, "A"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(2, "B"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(9, "I"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(10, "J"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(113, "KEY 113"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(114, "KEY 114"): special_keys.COLORSPLUS,
|
||||
common.NamedInt(1, "A"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(2, "B"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(9, "I"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(10, "J"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(113, "KEY 113"): _PERKEY_COLOR_RANGE,
|
||||
common.NamedInt(114, "KEY 114"): _PERKEY_COLOR_RANGE,
|
||||
},
|
||||
fake_hidpp.Response("00000606000000000000000000000000", 0x0400, "0000"), # first group of keys
|
||||
fake_hidpp.Response("00000600000000000000000000000000", 0x0400, "0001"), # second group of keys
|
||||
|
|
|
|||
Loading…
Reference in New Issue