PerKey dialog: one window per device, keyed by firmware unit-id

The editor was a process-wide singleton: opening it on a different
device replaced the content in the existing window. A user with two
perkey-capable devices (e.g. a G915 keyboard and a G502 mouse) had to
context-switch between them, losing any in-progress edits on the
device they switched away from.

Replace the singleton with a `_dialogs` dict in dialog.py keyed by a
stable per-device identifier. control.py builds the key from
`device.unitId` first — read from the device firmware via the
DeviceInformation feature, the same regardless of whether the device
is currently on a receiver or plugged directly via USB — so the same
physical device on different transports shares one dialog instead of
opening two windows. Falls back to `serial`, `hid_serial`, `codename`,
and finally `id(sink)` for the pathological case where nothing else
identifies the device.

If the dialog is already open for a given device and `present()` is
called with the same sink instance, the window is just raised — no
flicker, in-progress interaction state preserved. A different sink
under the same key (transport change for the same physical device)
rebuilds the window content under the existing dialog slot, so the
window position is preserved across transport switches.

Closing a window via the WM tears down only that dialog and pops it
from the registry; other open editors stay up. As a side cleanup the
unused `inset` ScrolledWindow shadow already added in editor.py and
the per-device sizing logic in dialog.py remain in place.
This commit is contained in:
Ken Sanislo 2026-05-11 01:08:47 -07:00 committed by Peter F. Patel-Schneider
parent 6c99d2f9d1
commit b594c7292b
3 changed files with 81 additions and 33 deletions

View File

@ -248,5 +248,17 @@ class PerKeyControl(Gtk.Box):
"zone_count": len(self._sink.zones),
}
layout = layout_for(feature_int, hint)
dlg = dialog_mod.get_dialog()
# Stable per-device key so the same physical device on USB and on
# the receiver shares a single dialog. unitId is read from the
# device firmware (via DeviceInformation) and is the same across
# transports; serial is per-pairing-slot. id(self._sink) is a
# last-resort fallback that should never be hit in practice.
key = (
getattr(device, "unitId", None)
or getattr(device, "serial", None)
or getattr(device, "hid_serial", None)
or getattr(device, "codename", None)
or id(self._sink)
)
dlg = dialog_mod.get_dialog(key)
dlg.present(self._sink, layout)

View File

@ -14,11 +14,18 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Singleton dialog hosting a PerKeyEditor for one sink at a time."""
"""Per-device dialogs hosting a PerKeyEditor.
One dialog instance is kept per device key (firmware unit-id, falling
back to other stable identifiers see ``get_dialog``). The same
physical device on different transports (receiver vs direct USB) shares
a key so it doesn't open two windows.
"""
from __future__ import annotations
from enum import Enum
from typing import Hashable
import gi
@ -36,51 +43,77 @@ class GtkSignal(Enum):
DELETE_EVENT = "delete-event"
class PerKeyEditorDialog:
_instance: "PerKeyEditorDialog | None" = None
_dialogs: dict[Hashable, "PerKeyEditorDialog"] = {}
def __init__(self) -> None:
self._window = Gtk.Window()
self._window.set_title(_("Per-key Lighting"))
# No default size or geometry hints — the editor's content size
# (driven by KeyboardCanvas's size_request) determines the window size
# via the ScrolledWindow's propagate_natural_size. Wide keyboards open
# large; small mice open small.
self._window.connect(GtkSignal.DELETE_EVENT.value, self._on_delete)
class PerKeyEditorDialog:
def __init__(self, key: Hashable) -> None:
self._key = key
self._window: Gtk.Window | None = None
self._wrapper: Gtk.Box | None = None
self._editor: PerKeyEditor | None = None
self._sink: PerKeyColorSink | None = None
def _on_delete(self, _w, _e) -> bool:
self._destroy()
_dialogs.pop(self._key, None)
return True
def _destroy(self) -> None:
if self._editor is not None:
self._editor.shutdown()
self._editor = None
if self._window is not None:
self._window.destroy()
self._window = None
self._wrapper = None
self._sink = None
def present(self, sink: PerKeyColorSink, layout: Layout | None) -> None:
# Re-opening for the same sink while the window is already open:
# just raise it (no rebuild flicker, preserves any in-progress
# interaction state).
if self._window is not None and self._sink is sink:
self._window.present()
return
# Otherwise build a fresh window. We always recreate rather than
# swap content in place because Gtk.Window.resize() after first
# show is unreliable across X11/Wayland WMs — the WM often keeps
# the original geometry — and a new window picks up the layout's
# natural size cleanly on first show.
self._destroy()
self._sink = sink
self._window = Gtk.Window()
self._window.set_title(_("Per-key Lighting") + "" + sink.title)
self._window.connect(GtkSignal.DELETE_EVENT.value, self._on_delete)
self._wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._wrapper.set_border_width(8)
self._window.add(self._wrapper)
def _on_delete(self, _w, _e) -> bool:
self._window.hide()
if self._editor is not None:
self._editor.shutdown()
self._wrapper.remove(self._editor)
self._editor = None
return True
def present(self, sink: PerKeyColorSink, layout: Layout | None) -> None:
if self._editor is not None:
self._editor.shutdown()
self._wrapper.remove(self._editor)
self._editor = None
self._editor = PerKeyEditor(sink, layout)
self._wrapper.pack_start(self._editor, True, True, 0)
self._wrapper.show_all()
self._window.set_title(_("Per-key Lighting") + "" + sink.title)
# Ask GTK what the wrapper actually wants to be — the canvas's
# size_request propagates up through ScrolledWindow + editor VBox
# (toolbar + scrolled canvas) + the wrapper's border, so the
# natural size already accounts for every layout contribution
# rather than hardcoding "toolbar ~50, border 8 each side".
# natural size already accounts for every layout contribution.
_min, nat = self._wrapper.get_preferred_size()
if nat.width > 0 and nat.height > 0:
self._window.resize(nat.width, nat.height)
self._window.present()
def get_dialog() -> PerKeyEditorDialog:
if PerKeyEditorDialog._instance is None:
PerKeyEditorDialog._instance = PerKeyEditorDialog()
return PerKeyEditorDialog._instance
def get_dialog(key: Hashable) -> PerKeyEditorDialog:
"""Return the dialog for `key`, creating one if none is open.
`key` should be a stable per-device identifier. The caller (control.py)
builds it from `device.unitId` first that's read from the device
firmware via the DeviceInformation feature and is the same regardless
of whether the device is on a receiver or plugged directly via USB,
so the same physical device doesn't open two windows when its
transport changes.
"""
d = _dialogs.get(key)
if d is None:
d = PerKeyEditorDialog(key)
_dialogs[key] = d
return d

View File

@ -126,6 +126,9 @@ class PerKeyEditor(Gtk.Box):
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_propagate_natural_width(True)
scroll.set_propagate_natural_height(True)
# Inset frame around the keyboard so it reads as a distinct panel
# rather than floating flat against the dialog background.
scroll.set_shadow_type(Gtk.ShadowType.IN)
self._canvas = KeyboardCanvas()
self._canvas.connect(GtkSignal.PAINT.value, self._on_canvas_paint)
scroll.add(self._canvas)