common: render RGB color values as 0xrrggbb in config and solaar show

24-bit RGB values stored in PerKeyLighting per-key maps and in
LEDEffectSetting `.color` fields currently dump as decimal integers in
both the YAML config file and `solaar show` output:

    per-key-lighting: {1: -1, 2: 16733440, 3: 16755200, ...}
    LEDs Keys: {1:-1, 2:16733440, 3:16755200, ...}

Hex is the canonical RGB representation. Render colors as `0xrrggbb`
everywhere: solaar show output, repr, and YAML config dumps. Both new
values and legacy values from pre-existing YAML configs migrate
transparently.

Implementation: a `ColorInt(int)` subclass in `common.py`.

  - `str(c)` / `repr(c)` → `'0xrrggbb'` for 0..0xFFFFFF; falls back to
    decimal for out-of-range values so sentinels like
    `COLORSPLUS["No change"] = -1` still display naturally.
  - Constructor accepts ints AND hex strings (`'0xrrggbb'` or
    `'#rrggbb'`) so pre-existing configs that wrote decimal continue
    to load.
  - YAML representer emits a hex int literal (`tag:yaml.org,2002:int`
    with style `'0xrrggbb'`). YAML 1.1 parses hex int literals back as
    plain ints with no custom loader registration — values round-trip
    cleanly without a custom YAML tag.

Wiring:

  - `Range` gains a `value_type=int` field; `MapRangeValidator.validate_read`
    wraps results through `rng.value_type(...)`. PerKeyLighting's
    `_COLOR_RANGE` sets `value_type=ColorInt`; other Range users keep
    the int default with no behavior change.
  - PerKeyLighting overrides `update()` and `update_key_value()` to wrap
    raw ints in `ColorInt` at write time. `type(v) is int` (exact
    match, not isinstance) deliberately excludes NamedInt sentinels and
    avoids re-wrapping existing ColorInts.
  - `LEDEffectSetting.__init__` wraps the `color` param in `ColorInt`
    with the same guard, so zone-effect color round-trips as hex
    through `yaml.dump(setting)` / `val_to_string`.
  - `MapRangeValidator.to_string` re-wraps raw ints loaded from YAML
    (which `yaml.safe_load` returns as plain Python ints regardless of
    the choice's `value_type`) through `rng.value_type` before
    formatting. Without this, `solaar show` would render legacy saved
    values as decimal even after the rest of the pipeline is hex-aware.

Coverage: 12 new unit tests across `test_common.py` and
`test_settings_validator.py`:

  - ColorInt str/repr, equality with plain int, hex-string constructor
    (0x / 0X / # prefixes), out-of-range fallback to decimal, YAML
    dump format, plain-int round-trip on load, dict-value formatting.
  - MapRangeValidator.to_string: plain-int re-wrap via value_type,
    pass-through for already-wrapped ColorInts, NamedInt sentinel
    preservation, and no behavior change for int-typed Ranges.

Existing test fixtures updated: _PERKEY_COLOR_RANGE now carries
value_type=ColorInt to match runtime _COLOR_RANGE.

702 tests pass; pre-commit clean.
This commit is contained in:
Ken Sanislo 2026-05-10 23:23:53 -07:00 committed by Peter F. Patel-Schneider
parent 671c07d861
commit 9b627410b6
7 changed files with 206 additions and 4 deletions

View File

@ -361,6 +361,53 @@ yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
yaml.add_representer(NamedInt, NamedInt.to_yaml)
class ColorInt(int):
"""A 24-bit RGB color (``0x000000``-``0xFFFFFF``) as an int subclass.
Renders as ``0xrrggbb`` in ``str()`` / ``repr()`` and as a YAML hex int
literal in dumped configs (e.g. ``color: 0xfc3300``), which loads back
natively as a plain int via YAML 1.1's hex int parsing — so the value
round-trips cleanly with no special loader registration. The constructor
accepts both ints and hex strings (``0xfc3300`` or ``#fc3300``) so configs
saved before this type existed continue to load unchanged.
Negative or out-of-range values fall back to plain decimal formatting so
sentinels like ``COLORSPLUS["No change"] = -1`` keep their natural display.
"""
def __new__(cls, value):
if isinstance(value, str):
s = value.strip().lower()
if s.startswith("#"):
value = int(s[1:], 16)
elif s.startswith(("0x", "0X")):
value = int(s, 16)
else:
value = int(s)
else:
value = int(value)
return super().__new__(cls, value)
def __str__(self):
v = int(self)
if 0 <= v <= 0xFFFFFF:
return "0x%06x" % v
return str(v)
def __repr__(self):
return self.__str__()
def color_int_representer(dumper, data):
v = int(data)
if 0 <= v <= 0xFFFFFF:
return dumper.represent_scalar("tag:yaml.org,2002:int", "0x%06x" % v)
return dumper.represent_scalar("tag:yaml.org,2002:int", str(v))
yaml.add_representer(ColorInt, color_int_representer)
class NamedInts:
"""An ordered set of NamedInt values.

View File

@ -1177,9 +1177,17 @@ LEDEffects = {
class LEDEffectSetting: # an effect plus its parameters
# Params whose value space is an RGB color; wrapped in ColorInt so the
# value self-formats as ``0xrrggbb`` in solaar show and the YAML config.
_COLOR_PARAMS = (str(LEDParam.color),)
def __init__(self, **kwargs):
self.ID = None
for key, val in kwargs.items():
# type(val) is int — exact match excludes NamedInt/ColorInt and
# any other int subclass; only "raw" ints get wrapped here.
if key in self._COLOR_PARAMS and type(val) is int and 0 <= val <= 0xFFFFFF: # noqa: E721
val = common.ColorInt(val)
setattr(self, key, val)
@classmethod

View File

@ -1895,6 +1895,26 @@ class PerKeyLighting(settings.Settings):
keys_universe = special_keys.KEYCODES
editor_class = "solaar.ui.perkey.control:PerKeyControl"
@staticmethod
def _wrap_color(value):
# Wrap raw 24-bit-range ints in ColorInt so saved configs render as
# ``0xrrggbb`` hex literals and `solaar show` prints hex. Sentinels
# (NamedInt "No change" = -1) and existing ColorInt values pass
# through untouched.
# type(value) is int — exact match excludes NamedInt sentinels like
# COLORSPLUS["No change"] = -1 and avoids re-wrapping ColorInts.
if type(value) is int and 0 <= value <= 0xFFFFFF: # noqa: E721
return common.ColorInt(value)
return value
def update(self, value, save=True):
if isinstance(value, dict):
value = {k: self._wrap_color(v) for k, v in value.items()}
super().update(value, save)
def update_key_value(self, key, value, save=True):
super().update_key_value(key, self._wrap_color(value), save)
def read(self, cached=True):
self._pre_read(cached)
if cached and self._value is not None:
@ -1951,7 +1971,7 @@ class PerKeyLighting(settings.Settings):
pass
class validator_class(settings_validator.MapRangeValidator):
_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3)
_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3, value_type=common.ColorInt)
@classmethod
def build(cls, setting_class, device):

View File

@ -754,6 +754,11 @@ 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.
`value_type` is the int factory used to wrap values returned from
`validate_read` defaults to `int`, but settings that store RGB colors
pass `common.ColorInt` so the values self-format as ``0xrrggbb`` in
`solaar show` output and the YAML config file.
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.
"""
@ -762,6 +767,7 @@ class Range:
max: int
byte_count: int = 1
signed: bool = False
value_type: type = int
def contains(self, value: int) -> bool:
return isinstance(value, int) and self.min <= value <= self.max
@ -795,14 +801,31 @@ class MapRangeValidator(Validator):
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)) + "}"
# Persisted dicts loaded from YAML come back as plain ints regardless
# of the choice's `value_type`. Re-wrap raw ints through the configured
# value_type (e.g. ColorInt) so they self-format consistently here and
# in `solaar show`. Skip wrapping for NamedInt sentinels / subclasses
# (`type(v) is int` is the exact-match guard).
rng_by_int_key = {int(k): rng for k, rng in self.choices.items()}
def _fmt(k):
v = value[k]
rng = rng_by_int_key.get(int(k))
if rng is not None and type(v) is int and rng.value_type is not int: # noqa: E721
try:
v = rng.value_type(v)
except Exception:
pass
return f"{k}:{v}"
return "{" + ", ".join(_fmt(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)
return rng.value_type(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")

View File

@ -65,6 +65,58 @@ def test_named_int_yaml():
assert yaml_load == named_int
def test_color_int_str_and_repr():
c = common.ColorInt(0xFC3300)
assert str(c) == "0xfc3300"
assert repr(c) == "0xfc3300"
assert int(c) == 0xFC3300
def test_color_int_equality_with_plain_int():
assert common.ColorInt(0xFC3300) == 0xFC3300
assert isinstance(common.ColorInt(0), int)
def test_color_int_from_hex_string_prefixes():
assert common.ColorInt("0xfc3300") == 0xFC3300
assert common.ColorInt("0XFC3300") == 0xFC3300
assert common.ColorInt("#fc3300") == 0xFC3300
def test_color_int_from_int_passes_through():
assert common.ColorInt(0) == 0
assert common.ColorInt(0xFFFFFF) == 0xFFFFFF
def test_color_int_out_of_range_falls_back_to_decimal():
# Sentinel values like COLORSPLUS["No change"] = -1 should still render
# in their natural form when wrapped (though they normally aren't).
assert str(common.ColorInt(-1)) == "-1"
def test_color_int_yaml_dumps_as_hex_int_literal():
c = common.ColorInt(0xFC3300)
dumped = yaml.dump(c).strip()
assert dumped == "0xfc3300\n..." or dumped.startswith("0xfc3300")
def test_color_int_yaml_roundtrips_to_plain_int():
# yaml.safe_load returns a plain int (yaml 1.1 hex literal parsing).
# The value matches; type promotion to ColorInt happens lazily at
# validator/setting boundaries.
c = common.ColorInt(0xFC3300)
loaded = yaml.safe_load(yaml.dump(c))
assert loaded == 0xFC3300
def test_color_int_in_dict_yaml_dumps_each_value_as_hex():
data = {1: common.ColorInt(0xFC3300), 2: common.ColorInt(0x00FF00), 3: -1}
dumped = yaml.dump(data, default_flow_style=None, width=150).strip()
assert "0xfc3300" in dumped
assert "0x00ff00" in dumped
assert "-1" in dumped # sentinel preserved
def test_named_ints():
named_ints = common.NamedInts(empty=0, critical=5, low=20, good=50, full=90)

View File

@ -33,7 +33,7 @@ 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)
_PERKEY_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3, value_type=common.ColorInt)
# TODO action part of DpiSlidingXY, MouseGesturesXY

View File

@ -1,5 +1,6 @@
import pytest
from logitech_receiver import common
from logitech_receiver import settings_validator
@ -23,3 +24,54 @@ def test_bool_or_toggle(current, new, expected):
result = settings_validator.bool_or_toggle(current=current, new=new)
assert result == expected
def _color_validator():
rng = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3, value_type=common.ColorInt)
choices = {
common.NamedInt(1, "A"): rng,
common.NamedInt(2, "B"): rng,
common.NamedInt(3, "C"): rng,
}
return settings_validator.MapRangeValidator(choices)
def test_map_range_to_string_formats_plain_int_through_value_type():
"""Configs loaded from YAML come back as plain ints; to_string should
re-wrap them via the choice's value_type so `solaar show` renders hex
regardless of whether the dict came from a fresh read or a stale load."""
v = _color_validator()
plain = {1: 12590120, 2: 12922150, 3: 16106001} # what YAML load produces
rendered = v.to_string(plain)
assert "0xc01c28" in rendered
assert "0xc52d26" in rendered
assert "0xf5c211" in rendered
def test_map_range_to_string_passes_color_int_through():
v = _color_validator()
wrapped = {1: common.ColorInt(0xFC3300), 2: common.ColorInt(0x00FF00)}
rendered = v.to_string(wrapped)
assert "0xfc3300" in rendered
assert "0x00ff00" in rendered
def test_map_range_to_string_preserves_sentinel_subclass():
"""NamedInt 'No change' = -1 must not be re-wrapped (its name would be
lost). The exact-type guard `type(v) is int` excludes it."""
v = _color_validator()
mixed = {1: common.ColorInt(0xFC3300), 2: -1}
rendered = v.to_string(mixed)
assert "0xfc3300" in rendered
assert "2:-1" in rendered
def test_map_range_to_string_int_value_type_unchanged():
"""When value_type is the default int, to_string emits decimal as before
(no behavior change for non-color settings)."""
rng = settings_validator.Range(min=0, max=255, byte_count=1)
choices = {common.NamedInt(1, "A"): rng, common.NamedInt(2, "B"): rng}
v = settings_validator.MapRangeValidator(choices)
rendered = v.to_string({1: 42, 2: 200})
assert "1:42" in rendered
assert "2:200" in rendered