diff --git a/lib/logitech_receiver/common.py b/lib/logitech_receiver/common.py index 3aa4127e..54dba5d3 100644 --- a/lib/logitech_receiver/common.py +++ b/lib/logitech_receiver/common.py @@ -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. diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index a377cd44..d000b409 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -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 diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 627aebab..ef5a7688 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -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): diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py index 92752640..267cf4e5 100644 --- a/lib/logitech_receiver/settings_validator.py +++ b/lib/logitech_receiver/settings_validator.py @@ -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") diff --git a/tests/logitech_receiver/test_common.py b/tests/logitech_receiver/test_common.py index b4ed9512..0d6124ca 100644 --- a/tests/logitech_receiver/test_common.py +++ b/tests/logitech_receiver/test_common.py @@ -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) diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 69793819..582e601f 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -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 diff --git a/tests/logitech_receiver/test_settings_validator.py b/tests/logitech_receiver/test_settings_validator.py index 383fae22..eede73ce 100644 --- a/tests/logitech_receiver/test_settings_validator.py +++ b/tests/logitech_receiver/test_settings_validator.py @@ -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