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:
parent
671c07d861
commit
9b627410b6
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue