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())