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:
parent
6c99d2f9d1
commit
b594c7292b
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue