From 625c95f09c8d926ad59f71c9386233251c8f20ca Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Fri, 23 Oct 2026 19:37:44 +0200 Subject: [PATCH] Lighting menu in the window for pads that have backlight modes A backend that lists lighting_modes gets a Lighting button in the footer: one entry per mode and a colour picker for the custom one. The tick follows what the pad reported in its last read, since every read of an ITPROTECH pad carries the lighting along with the keys. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- macropad_gui/app.py | 100 ++++++++++++++++++++++++++++- macropad_gui/backends/itprotech.py | 19 +++++- tests/test_itprotech.py | 79 +++++++++++++++++++++++ 4 files changed, 197 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4de6b64..3e36833 100644 --- a/README.md +++ b/README.md @@ -448,7 +448,7 @@ What the hardware can and can't do: | Key sequences | Yes, up to 10 keys with **one** set of modifiers for the whole sequence, no delays | | Mouse actions | The pad can, the app doesn't offer them yet | | Layers | None | -| Lighting | 15 modes and a custom colour, from the command line: `macropad lighting rgb "press: blue"`, `macropad lighting colour ff8800`, `macropad lighting show` | +| Lighting | 15 modes and a custom colour, from the **Lighting** menu at the bottom of the window or the command line: `macropad lighting rgb "press: blue"`, `macropad lighting colour ff8800`, `macropad lighting show` | Other sizes under vendor `8808` are found and offered **Ask the pad what it is**, which reads only. If the pad answers like an MK-09 does, the app adopts it. If you have one, a report from that button is all it takes to add it to the confirmed list. diff --git a/macropad_gui/app.py b/macropad_gui/app.py index 386dc7b..fab1dc3 100644 --- a/macropad_gui/app.py +++ b/macropad_gui/app.py @@ -17,7 +17,7 @@ from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget, - QCheckBox, QComboBox, QMenu) + QCheckBox, QColorDialog, QComboBox, QMenu) from . import __version__, actions as act, core, keymap, padview, themes from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView @@ -951,6 +951,20 @@ class Window(QMainWindow): stamp = self.theme_menu.addAction(f"MacroPad {__version__}") stamp.setEnabled(False) self.theme_btn.setMenu(self.theme_menu) + # Lighting is pad wide, so it lives down here with the other things + # that aren't about one key. Only pads whose backend offers modes + # get the button; it is filled in when such a pad turns up. + self.light_btn = _button("Lighting", "flat") + self.light_btn.setToolTip("Backlight mode and colour, stored on the pad") + self.light_menu = QMenu(self) + self.light_group = QActionGroup(self) + self.light_group.setExclusive(True) + self.light_actions = {} + self.light_custom = None + self.light_backend = None + self.light_colour = None + self.light_btn.setMenu(self.light_menu) + self.light_btn.hide() self.rotate = _button("Rotate pad", "flat") self.import_btn = _button("Import capture…", "flat") self.write_all = _button("", "primary") @@ -961,6 +975,7 @@ class Window(QMainWindow): fl.addWidget(self.edit_btn) fl.addWidget(self.read_btn) fl.addWidget(self.theme_btn) + fl.addWidget(self.light_btn) fl.addWidget(self.rotate) fl.addWidget(self.import_btn) fl.addWidget(self.write_all) @@ -984,6 +999,7 @@ class Window(QMainWindow): self.rotate.clicked.connect(self._rotate) self.style_group.triggered.connect(lambda a: self._set_theme(a.data())) self.colour_group.triggered.connect(lambda a: self._set_theme(a.data())) + self.light_group.triggered.connect(lambda a: self._set_lighting(a.data())) self.import_btn.clicked.connect(self._import) self.note = core._keyd_warning() @@ -1213,8 +1229,10 @@ class Window(QMainWindow): dev = self.diag.device if self.diag else None if not dev: self.inspector.backend = None + self._offer_lighting(None) return False self.inspector.backend = core.backend_for(dev) + self._offer_lighting(self.inspector.backend) ident = f"{dev.vid}:{dev.pid}" if ident == self.state.device_id: return False @@ -1321,6 +1339,7 @@ class Window(QMainWindow): else: n = len(got) self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD) + self._sync_lighting_from_pad() finally: self.read_btn.setEnabled(True) self.read_btn.setText("Read pad") @@ -1403,6 +1422,85 @@ class Window(QMainWindow): self.message.setText(text) self.message.setStyleSheet(f"color: {colour};" if colour else "") + # ---------------------------------------------------------- lighting + + def _offer_lighting(self, backend): + """Show the Lighting menu for a pad that has modes, hide it otherwise.""" + modes = getattr(backend, "lighting_modes", None) + if not modes: + self.light_btn.hide() + self.light_backend = None + return + if backend is not self.light_backend: + self.light_backend = backend + self.light_menu.clear() + for a in list(self.light_group.actions()): + self.light_group.removeAction(a) + self.light_actions = {} + custom = getattr(backend, "lighting_custom", None) + for i, name in enumerate(modes): + if i == custom: + continue + a = self.light_menu.addAction(name.capitalize()) + a.setCheckable(True) + a.setData(i) + self.light_group.addAction(a) + self.light_actions[i] = a + self.light_custom = None + if custom is not None: + self.light_menu.addSeparator() + self.light_custom = self.light_menu.addAction("Custom colour…") + self.light_custom.setCheckable(True) + self.light_custom.setData(custom) + self.light_group.addAction(self.light_custom) + self.light_actions[custom] = self.light_custom + self._sync_lighting_from_pad() + self.light_btn.show() + + def _sync_lighting_from_pad(self): + """Tick the mode the pad reported in its last read, if it did.""" + backend, dev = self.light_backend, self.diag.device if self.diag else None + if backend is None or dev is None or not hasattr(backend, "last_lighting"): + return + got = backend.last_lighting(dev) + if got: + self._sync_light_menu(*got) + + def _sync_light_menu(self, mode, colour): + a = self.light_actions.get(mode) + if a is not None: + a.setChecked(True) + if colour is not None: + self.light_colour = tuple(colour) + if self.light_custom is not None: + hexcol = "#%02x%02x%02x" % self.light_colour if self.light_colour else "" + self.light_custom.setText(f"Custom colour… {hexcol}".rstrip()) + + def _set_lighting(self, mode, colour=None): + """Send a lighting mode to the pad; the custom one asks for a colour.""" + backend, dev = self.light_backend, self.diag.device if self.diag else None + if backend is None or dev is None or not (self.diag and self.diag.ready): + self._say(self.diag.headline if self.diag else "No pad.", BAD) + self._sync_lighting_from_pad() + return + if mode == getattr(backend, "lighting_custom", None) and colour is None: + start = QColor(*self.light_colour) if self.light_colour else QColor("white") + picked = QColorDialog.getColor(start, self, "Custom colour") + if not picked.isValid(): + self._sync_lighting_from_pad() + return + colour = (picked.red(), picked.green(), picked.blue()) + try: + backend.set_lighting(dev, mode, colour) + except (OSError, ValueError) as e: + self._say(f"Couldn't set the lighting: {core._explain(e) if isinstance(e, OSError) else e}", BAD) + self._sync_lighting_from_pad() + return + self._sync_light_menu(mode, colour) + name = backend.lighting_modes[mode] + extra = " #%02x%02x%02x" % tuple(colour) if colour else "" + self._say(f"Lighting: {name}{extra}.", GOOD) + # ------------------------------------------------------------- misc def _edit_layout(self, on, keep=False): diff --git a/macropad_gui/backends/itprotech.py b/macropad_gui/backends/itprotech.py index bc264a3..21b5cff 100644 --- a/macropad_gui/backends/itprotech.py +++ b/macropad_gui/backends/itprotech.py @@ -14,7 +14,8 @@ Three things about this firmware shape the code: read again and has to come back byte for byte as sent. A write that doesn't is reported as such, and the control is marked unknown. -Lighting and colour have no place in the GUI yet; they are a subcommand: +Lighting is pad wide rather than per key, so the GUI offers it in a +Lighting menu at the foot of the window, and the CLI as a subcommand: macropad lighting show macropad lighting rgb "press: blue" @@ -37,9 +38,12 @@ class Itprotech(Backend): max_keys = 12 max_knobs = 0 sequences = True # multikey: several keys, one modifier mask + lighting_modes = wire.RGB_MODES # offered by the GUI's Lighting menu + lighting_custom = wire.RGB_CUSTOM # the mode that takes a colour def __init__(self): self._data_len = {} # device path -> key record length seen + self._lighting = {} # device path -> (mode, colour) last read # ------------------------------------------------------------ protocol @@ -79,6 +83,8 @@ class Itprotech(Backend): dump = wire.parse_dump(replies) if dump["data_len"]: self._data_len[device.path] = dump["data_len"] + if dump["rgb"] is not None: + self._lighting[device.path] = (dump["rgb"], dump["colour"]) return dump def info(self, device): @@ -105,6 +111,15 @@ class Itprotech(Backend): dump = self._dump(device) return dump["rgb"], dump["colour"] + def last_lighting(self, device): + """ + (mode, colour) from the most recent read of this pad, or None. + + Every read returns the lighting along with the keys, so the GUI can + show it without asking the pad again. + """ + return self._lighting.get(device.path) + # --------------------------------------------------------------- write def _send(self, device, report, _write=None): @@ -164,6 +179,8 @@ class Itprotech(Backend): self._send(device, wire.rgb_frame(mode), _write) if mode == wire.RGB_CUSTOM and colour is not None: self._send(device, wire.colour_frame(*colour), _write) + old = self._lighting.get(device.path, (None, None)) + self._lighting[device.path] = (mode, colour if colour is not None else old[1]) # -------------------------------------------------------------- detect diff --git a/tests/test_itprotech.py b/tests/test_itprotech.py index 3ca3ff0..66f32ec 100644 --- a/tests/test_itprotech.py +++ b/tests/test_itprotech.py @@ -334,3 +334,82 @@ class Registry(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class Lighting(unittest.TestCase): + + def setUp(self): + self.pad = FakePad() + self.backend = Itprotech() + patch = mock.patch.object(core, "_exchange", self.pad.exchange) + patch.start() + self.addCleanup(patch.stop) + + def test_the_backend_offers_its_modes_to_the_gui(self): + self.assertEqual(len(self.backend.lighting_modes), 15) + self.assertEqual(self.backend.lighting_modes[self.backend.lighting_custom], + "custom colour") + + def test_a_read_remembers_the_lighting(self): + dev = fake_device() + self.assertIsNone(self.backend.last_lighting(dev)) + self.backend.read_layer(dev) + self.assertEqual(self.backend.last_lighting(dev), (12, (255, 255, 255))) + + def test_setting_updates_what_is_remembered(self): + dev = fake_device() + self.backend.read_layer(dev) + self.backend.set_lighting(dev, 3, _write=self.pad.write_report) + self.assertEqual(self.backend.last_lighting(dev), (3, (255, 255, 255))) + self.backend.set_lighting(dev, wire.RGB_CUSTOM, (1, 2, 3), + _write=self.pad.write_report) + self.assertEqual(self.backend.last_lighting(dev), (14, (1, 2, 3))) + + +class LightingMenu(unittest.TestCase): + """The GUI's Lighting menu, offscreen, against the fake pad.""" + + def test_the_menu_appears_for_this_pad_and_writes_to_it(self): + import os + try: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from macropad_gui import app + except ImportError: + self.skipTest("PySide6 not installed") + import macropad as proto + pad = FakePad() + dev = fake_device() + ready = core.Diagnosis("ready", f"Ready on {dev.path}.", device=dev) + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}), \ + mock.patch.object(core, "diagnose", return_value=ready), \ + mock.patch.object(core, "_exchange", pad.exchange), \ + mock.patch.object(proto, "write_report", pad.write_report): + QApplication.instance() or QApplication([]) + w = app.Window() + w.timer.stop() + w._read(quiet=True) + self.assertFalse(w.light_btn.isHidden()) + self.assertEqual(w.state.device_id, "8808:660a") + self.assertEqual(w.state.layout.describe(), "9 keys in 3x3") + # the pad said mode 12, so that one is ticked + self.assertTrue(w.light_actions[12].isChecked()) + self.assertIn("#ffffff", w.light_custom.text()) + # choosing a mode sends it, twice, and ticks it + w.light_actions[3].trigger() + sent = [r for r in pad.written if r[1:3] == bytes([2, wire.CMD_RGB])] + self.assertEqual([r[3] for r in sent], [3, 3]) + self.assertEqual([a[3] for a in pad.acted if a[2] == wire.CMD_RGB], [3]) + self.assertTrue(w.light_actions[3].isChecked()) + self.assertIn("press: green", w.message.text()) + # a custom colour chosen without the dialog goes out as mode 14 + colour + w._set_lighting(wire.RGB_CUSTOM, (0x10, 0x20, 0x30)) + colours = [a for a in pad.acted if a[2] == wire.CMD_COLOUR] + self.assertEqual(colours[-1][3:6], bytes([0x20, 0x10, 0x30])) + self.assertTrue(w.light_custom.isChecked()) + self.assertIn("#102030", w.light_custom.text()) + # no pad with lighting: the button goes away + w.diag = core.Diagnosis("unplugged", "No pad connected.") + w._follow_device() + self.assertTrue(w.light_btn.isHidden())