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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 19:37:44 +02:00
parent 4f33161c70
commit 625c95f09c
4 changed files with 197 additions and 3 deletions

View File

@@ -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):

View File

@@ -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