muxplex-deck 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deck_probe/__init__.py +14 -0
- deck_probe/__main__.py +6 -0
- deck_probe/capabilities.py +130 -0
- deck_probe/events.py +211 -0
- deck_probe/main.py +220 -0
- deck_probe/rendering.py +166 -0
- muxplex_deck/__init__.py +5 -0
- muxplex_deck/__main__.py +11 -0
- muxplex_deck/attention.py +81 -0
- muxplex_deck/cli.py +1122 -0
- muxplex_deck/config.py +267 -0
- muxplex_deck/device.py +101 -0
- muxplex_deck/device_real.py +157 -0
- muxplex_deck/emulator.py +530 -0
- muxplex_deck/focus.py +92 -0
- muxplex_deck/init_wizard.py +382 -0
- muxplex_deck/interaction.py +418 -0
- muxplex_deck/layout.py +211 -0
- muxplex_deck/main.py +1093 -0
- muxplex_deck/rendering.py +440 -0
- muxplex_deck/service.py +551 -0
- muxplex_deck/statusfile.py +176 -0
- muxplex_deck/views.py +92 -0
- muxplex_deck-0.4.0.dist-info/METADATA +639 -0
- muxplex_deck-0.4.0.dist-info/RECORD +27 -0
- muxplex_deck-0.4.0.dist-info/WHEEL +4 -0
- muxplex_deck-0.4.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Dial-driven interaction state: view cycling (dial 0) and paging (dial 1).
|
|
2
|
+
|
|
3
|
+
Pure state machines with no I/O -- `main.py` wires these to the device's
|
|
4
|
+
dial callback and to `MuxplexClient`/`rendering`. Keeping the *logic* here
|
|
5
|
+
(list position bookkeeping, debounce timing, page clamping) separate from
|
|
6
|
+
*painting* and *networking* keeps both testable and independently
|
|
7
|
+
regeneratable.
|
|
8
|
+
|
|
9
|
+
Thread-safety: dial turn/press events arrive on a device-callback thread --
|
|
10
|
+
the `streamdeck` library's own read thread for real hardware, or one of the
|
|
11
|
+
emulator's HTTP handler threads for `--emulator` -- while `main.py`'s poll
|
|
12
|
+
loop reads/refreshes this state from the main thread every `poll_interval`
|
|
13
|
+
seconds. Both classes guard their own state with an internal lock so callers
|
|
14
|
+
never need to coordinate access themselves.
|
|
15
|
+
|
|
16
|
+
`active_view` semantics (dial 0): muxplex's `active_view` is *global*
|
|
17
|
+
server-side state -- one value, last writer wins, shared by every
|
|
18
|
+
device/browser tab watching the server. `ViewCycler` mirrors that: turning
|
|
19
|
+
the dial changes what *everyone* sees, exactly like a browser tab switching
|
|
20
|
+
views would. There is no per-device view.
|
|
21
|
+
|
|
22
|
+
`PickerController` (dial-press picker mode): pressing dial 0 or dial 1 no
|
|
23
|
+
longer performs an immediate action (jump to "all" / reset to page 1) --
|
|
24
|
+
instead it hands the 8 keys over to a chooser listing that dial's options
|
|
25
|
+
(views, or page numbers), so a spin-then-tap sequence can jump straight to
|
|
26
|
+
a specific target on a server with more options than there are keys. This
|
|
27
|
+
class only tracks *which* picker (if any) owns the keys and its scroll
|
|
28
|
+
window -- it has no idea what the actual view names or page count are
|
|
29
|
+
(that's server-derived state `main.py` supplies at render/select time).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import math
|
|
35
|
+
import threading
|
|
36
|
+
from collections.abc import Callable, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from enum import Enum
|
|
39
|
+
|
|
40
|
+
from .layout import KEY_NEXT, KEY_PREV, KEY_VIEW
|
|
41
|
+
|
|
42
|
+
DEFAULT_DEBOUNCE_SECONDS = 0.4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ViewCycler:
|
|
46
|
+
"""Dial-0 state: local echo of the view being turned to + a debounced commit.
|
|
47
|
+
|
|
48
|
+
A "turn" accumulates tick counts without touching the server. Only after
|
|
49
|
+
`debounce_seconds` pass with no further ticks does the accumulated
|
|
50
|
+
position get committed -- via the caller-supplied `on_commit` callback,
|
|
51
|
+
invoked with the final view name -- so a fast multi-tick spin collapses
|
|
52
|
+
into exactly one server write. A press jumps straight to "all",
|
|
53
|
+
bypassing debounce entirely (an explicit, deliberate action).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, debounce_seconds: float = DEFAULT_DEBOUNCE_SECONDS) -> None:
|
|
57
|
+
self._debounce_seconds = debounce_seconds
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._names: list[str] = ["all"]
|
|
60
|
+
self._committed_view = "all"
|
|
61
|
+
self._pending_ticks = 0
|
|
62
|
+
self._timer: threading.Timer | None = None
|
|
63
|
+
|
|
64
|
+
def names(self) -> list[str]:
|
|
65
|
+
"""The current cycle list (`["all"] + <named views> + ["hidden"]`), a fresh copy."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
return list(self._names)
|
|
68
|
+
|
|
69
|
+
def sync(self, view_names: list[str], active_view: str) -> None:
|
|
70
|
+
"""Refresh the cycle list and the known server view from a fresh poll.
|
|
71
|
+
|
|
72
|
+
`view_names` should be `settings.views` names in server order
|
|
73
|
+
(before this method prepends "all" and appends "hidden").
|
|
74
|
+
|
|
75
|
+
"hidden" is a reserved pseudo-view, exactly like "all": it is never
|
|
76
|
+
a member of `settings.views` (the server's `views.RESERVED_VIEW_NAMES`
|
|
77
|
+
rejects it as a user view name), but `active_view` already accepts it
|
|
78
|
+
(GET/PATCH /api/state) and both the server's `filter_visible` and
|
|
79
|
+
this sidecar's own `views.resolve_view` already treat it as a
|
|
80
|
+
first-class case -- a session's hidden state is a property
|
|
81
|
+
orthogonal to view membership, not a bucket alongside user views.
|
|
82
|
+
The only gap was discoverability: this cycle list is the sidecar's
|
|
83
|
+
sole source of selectable pseudo-view/view names, so appending
|
|
84
|
+
"hidden" last (mirroring the PWA's own view dropdown, which
|
|
85
|
+
hardcodes "All Sessions" first and "Hidden" as the always-last
|
|
86
|
+
system view -- see `renderViewDropdown()` in muxplex's
|
|
87
|
+
`frontend/app.js`) makes it reachable via dial-0 (FULL layout:
|
|
88
|
+
normal spin-cycling and the dial-press picker) and the paged
|
|
89
|
+
VIEW-key picker (REDUCED layout) -- both consume this same
|
|
90
|
+
`names()` list, so this one change covers both hardware layouts.
|
|
91
|
+
|
|
92
|
+
Only updates the tracked "committed" view when no turn is currently
|
|
93
|
+
in flight (no pending debounce timer) -- otherwise a poll landing
|
|
94
|
+
mid-spin would clobber the turn in progress out from under the
|
|
95
|
+
user. Once the debounce fires (or a press happens), the next call
|
|
96
|
+
to `sync` resumes tracking the server's value normally -- this is
|
|
97
|
+
also what makes a *failed* PATCH self-heal: if `on_commit` raised,
|
|
98
|
+
the optimistic `_committed_view` set at commit time gets corrected
|
|
99
|
+
back to the real server value on the very next poll.
|
|
100
|
+
"""
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._names = (
|
|
103
|
+
["all"]
|
|
104
|
+
+ [n for n in view_names if n not in ("all", "hidden")]
|
|
105
|
+
+ ["hidden"]
|
|
106
|
+
)
|
|
107
|
+
if self._timer is None:
|
|
108
|
+
self._committed_view = active_view
|
|
109
|
+
|
|
110
|
+
def _base_index(self) -> int:
|
|
111
|
+
try:
|
|
112
|
+
return self._names.index(self._committed_view)
|
|
113
|
+
except ValueError:
|
|
114
|
+
# Deleted/unknown current view: treat as sitting just before
|
|
115
|
+
# "all" so the very next tick (either direction, since this is
|
|
116
|
+
# a ring) lands somewhere sane rather than raising.
|
|
117
|
+
return -1
|
|
118
|
+
|
|
119
|
+
def _label_at(self, pending_ticks: int) -> str:
|
|
120
|
+
if not self._names:
|
|
121
|
+
return "all"
|
|
122
|
+
index = (self._base_index() + pending_ticks) % len(self._names)
|
|
123
|
+
return self._names[index]
|
|
124
|
+
|
|
125
|
+
def is_turning(self) -> bool:
|
|
126
|
+
"""True while a turn's debounce timer is pending (not yet committed)."""
|
|
127
|
+
with self._lock:
|
|
128
|
+
return self._timer is not None
|
|
129
|
+
|
|
130
|
+
def candidate_view(self) -> str:
|
|
131
|
+
"""The view name that would commit right now (mid-turn or settled)."""
|
|
132
|
+
with self._lock:
|
|
133
|
+
return self._label_at(self._pending_ticks)
|
|
134
|
+
|
|
135
|
+
def turn(self, ticks: int, on_commit: Callable[[str], None]) -> str:
|
|
136
|
+
"""Register `ticks` of dial-0 rotation; returns the label to echo now.
|
|
137
|
+
|
|
138
|
+
(Re)schedules `on_commit(label)` to fire after `debounce_seconds` of
|
|
139
|
+
no further turns.
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._pending_ticks += ticks
|
|
143
|
+
label = self._label_at(self._pending_ticks)
|
|
144
|
+
if self._timer is not None:
|
|
145
|
+
self._timer.cancel()
|
|
146
|
+
timer = threading.Timer(
|
|
147
|
+
self._debounce_seconds, self._fire_commit, args=(on_commit,)
|
|
148
|
+
)
|
|
149
|
+
timer.daemon = True
|
|
150
|
+
self._timer = timer
|
|
151
|
+
timer.start()
|
|
152
|
+
return label
|
|
153
|
+
|
|
154
|
+
def _fire_commit(self, on_commit: Callable[[str], None]) -> None:
|
|
155
|
+
with self._lock:
|
|
156
|
+
label = self._label_at(self._pending_ticks)
|
|
157
|
+
self._committed_view = label
|
|
158
|
+
self._pending_ticks = 0
|
|
159
|
+
self._timer = None
|
|
160
|
+
on_commit(label)
|
|
161
|
+
|
|
162
|
+
def press(self, on_commit: Callable[[str], None]) -> str:
|
|
163
|
+
"""Dial-0 press: jump immediately to "all" (no debounce)."""
|
|
164
|
+
with self._lock:
|
|
165
|
+
if self._timer is not None:
|
|
166
|
+
self._timer.cancel()
|
|
167
|
+
self._timer = None
|
|
168
|
+
self._pending_ticks = 0
|
|
169
|
+
self._committed_view = "all"
|
|
170
|
+
on_commit("all")
|
|
171
|
+
return "all"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Pager:
|
|
175
|
+
"""Dial-1 state: local page position within the current view.
|
|
176
|
+
|
|
177
|
+
Purely local -- turning or pressing dial 1 never talks to the server.
|
|
178
|
+
`page` is 1-indexed. Turning clamps at the first/last page (no wrap,
|
|
179
|
+
unlike the view cycler); pressing jumps back to page 1.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, page_size: int) -> None:
|
|
183
|
+
self.page_size = max(1, page_size)
|
|
184
|
+
self._lock = threading.Lock()
|
|
185
|
+
self._page = 1
|
|
186
|
+
self._page_count = 1
|
|
187
|
+
|
|
188
|
+
def set_item_count(self, count: int) -> None:
|
|
189
|
+
"""Recompute page count from the current (view-resolved) item total.
|
|
190
|
+
|
|
191
|
+
Clamps the current page down if the view shrank below it (e.g. a
|
|
192
|
+
session closed, or switching to a smaller view without an explicit
|
|
193
|
+
reset -- belt-and-suspenders alongside `main.py`'s explicit
|
|
194
|
+
`reset()` on detected view changes).
|
|
195
|
+
"""
|
|
196
|
+
with self._lock:
|
|
197
|
+
self._page_count = max(1, math.ceil(count / self.page_size))
|
|
198
|
+
self._page = min(self._page, self._page_count)
|
|
199
|
+
|
|
200
|
+
def reset(self) -> None:
|
|
201
|
+
"""Back to page 1 -- called on every detected view change."""
|
|
202
|
+
with self._lock:
|
|
203
|
+
self._page = 1
|
|
204
|
+
|
|
205
|
+
def turn(self, ticks: int) -> int:
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._page = min(max(1, self._page + ticks), self._page_count)
|
|
208
|
+
return self._page
|
|
209
|
+
|
|
210
|
+
def press(self) -> int:
|
|
211
|
+
with self._lock:
|
|
212
|
+
self._page = 1
|
|
213
|
+
return self._page
|
|
214
|
+
|
|
215
|
+
def go_to(self, page: int) -> int:
|
|
216
|
+
"""Jump directly to `page` (1-indexed), clamped -- used by the page picker."""
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._page = min(max(1, page), self._page_count)
|
|
219
|
+
return self._page
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def page(self) -> int:
|
|
223
|
+
with self._lock:
|
|
224
|
+
return self._page
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def page_count(self) -> int:
|
|
228
|
+
with self._lock:
|
|
229
|
+
return self._page_count
|
|
230
|
+
|
|
231
|
+
def slice_bounds(self) -> tuple[int, int]:
|
|
232
|
+
"""(start, stop) index range for the current page, for list slicing."""
|
|
233
|
+
with self._lock:
|
|
234
|
+
start = (self._page - 1) * self.page_size
|
|
235
|
+
return start, start + self.page_size
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class PickerMode(Enum):
|
|
239
|
+
"""Which dial-press picker (if any) currently owns the 8 keys."""
|
|
240
|
+
|
|
241
|
+
NONE = "none"
|
|
242
|
+
VIEW = "view"
|
|
243
|
+
PAGE = "page"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class PickerController:
|
|
247
|
+
"""Dial-press picker state machine: NONE / VIEW / PAGE + a scroll window.
|
|
248
|
+
|
|
249
|
+
Replaces the old "press dial 0 -> jump to all" / "press dial 1 -> reset
|
|
250
|
+
to page 1" immediate actions with a chooser mode: pressing a dial hands
|
|
251
|
+
the 8 keys over to a list of that dial's options (view names, or page
|
|
252
|
+
numbers) so a spin-then-tap sequence can jump straight to a specific
|
|
253
|
+
target -- useful once there are more views or pages than there are keys.
|
|
254
|
+
|
|
255
|
+
Transition rules (all pure, no I/O):
|
|
256
|
+
- Press the dial that owns the *current* picker -> exit to NONE.
|
|
257
|
+
- Press the *other* dial while a picker is open -> switch straight to
|
|
258
|
+
that dial's picker (no intermediate NONE).
|
|
259
|
+
- Press either dial from NONE -> open that dial's picker.
|
|
260
|
+
A session-key tap while a picker is open means "select this option", not
|
|
261
|
+
"connect" -- that dispatch lives in `main.py` (it needs live server
|
|
262
|
+
state this class deliberately doesn't carry).
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
def __init__(self) -> None:
|
|
266
|
+
self._lock = threading.Lock()
|
|
267
|
+
self._mode = PickerMode.NONE
|
|
268
|
+
self._window_start = 0
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def mode(self) -> PickerMode:
|
|
272
|
+
with self._lock:
|
|
273
|
+
return self._mode
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def window_start(self) -> int:
|
|
277
|
+
with self._lock:
|
|
278
|
+
return self._window_start
|
|
279
|
+
|
|
280
|
+
def press_view_dial(self) -> PickerMode:
|
|
281
|
+
"""Dial-0 press: open the VIEW picker, or close it if already open."""
|
|
282
|
+
with self._lock:
|
|
283
|
+
self._mode = (
|
|
284
|
+
PickerMode.NONE if self._mode == PickerMode.VIEW else PickerMode.VIEW
|
|
285
|
+
)
|
|
286
|
+
self._window_start = 0
|
|
287
|
+
return self._mode
|
|
288
|
+
|
|
289
|
+
def press_page_dial(self) -> PickerMode:
|
|
290
|
+
"""Dial-1 press: open the PAGE picker, or close it if already open."""
|
|
291
|
+
with self._lock:
|
|
292
|
+
self._mode = (
|
|
293
|
+
PickerMode.NONE if self._mode == PickerMode.PAGE else PickerMode.PAGE
|
|
294
|
+
)
|
|
295
|
+
self._window_start = 0
|
|
296
|
+
return self._mode
|
|
297
|
+
|
|
298
|
+
def exit(self) -> None:
|
|
299
|
+
"""Close whichever picker is open (e.g. after a key-tap selection)."""
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._mode = PickerMode.NONE
|
|
302
|
+
self._window_start = 0
|
|
303
|
+
|
|
304
|
+
def scroll(self, ticks: int, *, total: int, page_size: int) -> int:
|
|
305
|
+
"""Move the scroll window by `ticks` pages of `page_size` options.
|
|
306
|
+
|
|
307
|
+
Clamped to `[0, last page start]`, no wrap -- matching `Pager.turn`'s
|
|
308
|
+
clamping style. `ticks=0` is a no-op *move* but still re-clamps the
|
|
309
|
+
stored window against a possibly-changed `total` (e.g. the view list
|
|
310
|
+
or page count changed since the window was last set) -- callers use
|
|
311
|
+
this to keep the window valid before every repaint.
|
|
312
|
+
"""
|
|
313
|
+
with self._lock:
|
|
314
|
+
self._window_start = clamp_window_start(
|
|
315
|
+
self._window_start + ticks * page_size, total=total, page_size=page_size
|
|
316
|
+
)
|
|
317
|
+
return self._window_start
|
|
318
|
+
|
|
319
|
+
def set_window(self, start: int) -> None:
|
|
320
|
+
"""Set the scroll window directly (already-clamped values only).
|
|
321
|
+
|
|
322
|
+
Used by the reduced-layout key picker, whose paging math lives in
|
|
323
|
+
the pure `handle_picker_key` -- the value it supplies came through
|
|
324
|
+
`clamp_window_start`, and every repaint re-clamps via `scroll(0)`
|
|
325
|
+
anyway, so a stale value can never paint out of range.
|
|
326
|
+
"""
|
|
327
|
+
with self._lock:
|
|
328
|
+
self._window_start = start
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def clamp_window_start(start: int, *, total: int, page_size: int) -> int:
|
|
332
|
+
"""Clamp a picker scroll-window start to `[0, last page start]`.
|
|
333
|
+
|
|
334
|
+
The one home for the picker's window math, shared by
|
|
335
|
+
`PickerController.scroll` (dial-driven scrolling, FULL layout) and
|
|
336
|
+
`handle_picker_key` (key-driven paging, REDUCED layout).
|
|
337
|
+
"""
|
|
338
|
+
max_start = 0 if total <= page_size else ((total - 1) // page_size) * page_size
|
|
339
|
+
return min(max(0, start), max_start)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --- Reduced-layout view picker (key-driven) ---------------------------------
|
|
343
|
+
#
|
|
344
|
+
# On dial-less decks the VIEW key opens a paged view picker on the session
|
|
345
|
+
# grid itself: the session-slot keys show view names, the VIEW key becomes
|
|
346
|
+
# BACK, and PREV/NEXT page the list. The *dispatch* -- what a physical key
|
|
347
|
+
# press means while the picker is open -- is this pure function; `main.py`
|
|
348
|
+
# owns the side effects (PATCH active_view, repaint) and `PickerController`
|
|
349
|
+
# owns which picker is open.
|
|
350
|
+
|
|
351
|
+
ACTION_CANCEL = "cancel" # BACK key: close the picker, change nothing
|
|
352
|
+
ACTION_SELECT = "select" # a view was chosen: PATCH it (server-global), close
|
|
353
|
+
ACTION_PAGE = "page" # PREV/NEXT: move the window, stay open
|
|
354
|
+
ACTION_IGNORE = "ignore" # empty slot / unknown key: do nothing, stay open
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True)
|
|
358
|
+
class PickerKeyResult:
|
|
359
|
+
"""Outcome of one key press while the reduced-layout picker is open.
|
|
360
|
+
|
|
361
|
+
`view` is the chosen option's exact name (ACTION_SELECT only).
|
|
362
|
+
`window_start` is the (clamped) scroll window after this press --
|
|
363
|
+
unchanged except for ACTION_PAGE.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
action: str
|
|
367
|
+
view: str | None = None
|
|
368
|
+
window_start: int = 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def handle_picker_key(
|
|
372
|
+
*,
|
|
373
|
+
kind: str,
|
|
374
|
+
slot: int | None,
|
|
375
|
+
options: Sequence[str],
|
|
376
|
+
window_start: int,
|
|
377
|
+
page_size: int,
|
|
378
|
+
) -> PickerKeyResult:
|
|
379
|
+
"""Pure dispatch: map a classified key press to a picker action.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
kind: the key's role from `layout.classify_key` (KEY_VIEW is the
|
|
383
|
+
BACK key while the picker is open; KEY_PREV/KEY_NEXT page).
|
|
384
|
+
slot: the session-slot position from `classify_key` (option index
|
|
385
|
+
within the current window), or None for a reserved/unknown key.
|
|
386
|
+
options: the full option list (view names, in the same order the
|
|
387
|
+
rest of the app uses -- `ViewCycler.names()`).
|
|
388
|
+
window_start: the picker's current scroll-window start.
|
|
389
|
+
page_size: options per page (the layout's `sessions_per_page`).
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
A `PickerKeyResult`; never raises, even for empty `options`.
|
|
393
|
+
"""
|
|
394
|
+
page_size = max(1, page_size)
|
|
395
|
+
total = len(options)
|
|
396
|
+
window_start = clamp_window_start(window_start, total=total, page_size=page_size)
|
|
397
|
+
|
|
398
|
+
if kind == KEY_VIEW:
|
|
399
|
+
return PickerKeyResult(ACTION_CANCEL, window_start=window_start)
|
|
400
|
+
if kind == KEY_PREV:
|
|
401
|
+
new_start = clamp_window_start(
|
|
402
|
+
window_start - page_size, total=total, page_size=page_size
|
|
403
|
+
)
|
|
404
|
+
return PickerKeyResult(ACTION_PAGE, window_start=new_start)
|
|
405
|
+
if kind == KEY_NEXT:
|
|
406
|
+
new_start = clamp_window_start(
|
|
407
|
+
window_start + page_size, total=total, page_size=page_size
|
|
408
|
+
)
|
|
409
|
+
return PickerKeyResult(ACTION_PAGE, window_start=new_start)
|
|
410
|
+
|
|
411
|
+
if slot is None:
|
|
412
|
+
return PickerKeyResult(ACTION_IGNORE, window_start=window_start)
|
|
413
|
+
index = window_start + slot
|
|
414
|
+
if index >= total:
|
|
415
|
+
return PickerKeyResult(ACTION_IGNORE, window_start=window_start)
|
|
416
|
+
return PickerKeyResult(
|
|
417
|
+
ACTION_SELECT, view=options[index], window_start=window_start
|
|
418
|
+
)
|
muxplex_deck/layout.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Capability-driven key layout planning: which keys do what on this deck.
|
|
2
|
+
|
|
3
|
+
Mirrors `deck_probe/capabilities.py`'s approach (pure dict in, decisions
|
|
4
|
+
out) without importing it -- the probe is a PoC, this is the product. The
|
|
5
|
+
hard rule is the same: **branch only on numeric/boolean capability values
|
|
6
|
+
(`key_count`, `key_layout`, `dial_count`, `is_touch`), never on
|
|
7
|
+
`deck_type()` strings** -- model names collide (Original vs MK2) and new
|
|
8
|
+
models would need matrix updates. Capabilities self-describe.
|
|
9
|
+
|
|
10
|
+
Two layout modes:
|
|
11
|
+
|
|
12
|
+
- FULL -- decks with at least two dials AND a touchscreen (the Stream
|
|
13
|
+
Deck+). Every LCD key is a session tile; dial 0 cycles views, dial 1
|
|
14
|
+
pages, and the touch strip carries the server/view/status headline.
|
|
15
|
+
This is the pre-existing Stream Deck+ behavior, unchanged.
|
|
16
|
+
- REDUCED -- everything else (Original/MK2 3x5, XL 4x8, Mini 2x3, ...).
|
|
17
|
+
With no dials or strip to lean on, three keys are reserved *by grid
|
|
18
|
+
position* (computed from `key_layout` rows x cols, never hardcoded)
|
|
19
|
+
for the roles the dials/strip played:
|
|
20
|
+
* VIEW = top-left (index 0): shows the current view name +
|
|
21
|
+
server label (what the strip showed); a tap opens a paged view
|
|
22
|
+
picker on the session-slot keys (dial-0's picker role) -- VIEW
|
|
23
|
+
becomes BACK, PREV/NEXT page the list, tapping a view selects it.
|
|
24
|
+
* PREV = bottom-left (index (rows-1)*cols): previous page.
|
|
25
|
+
* NEXT = bottom-right (index rows*cols-1): next page (dial-1's role).
|
|
26
|
+
Every remaining key is a session tile in reading order, so
|
|
27
|
+
sessions_per_page = key_count - 3 (12 on a 15-key deck).
|
|
28
|
+
|
|
29
|
+
Edge cases (degrade gracefully, never crash):
|
|
30
|
+
|
|
31
|
+
- Dials but no touchscreen: REDUCED. The key controls always work; the
|
|
32
|
+
dials are left unassigned rather than inventing a third half-mode.
|
|
33
|
+
- Touchscreen but fewer than two dials: REDUCED for the keys, but the
|
|
34
|
+
strip is still used for the status headline (`use_strip` stays True) --
|
|
35
|
+
free extra signal, no behavior depends on it.
|
|
36
|
+
- Fewer than 4 keys, or a grid so degenerate the three reserved corners
|
|
37
|
+
collide (e.g. a single row, where top-left == bottom-left): every key
|
|
38
|
+
becomes a session tile with no view/page controls -- a plain session
|
|
39
|
+
switcher beats three controls with no sessions.
|
|
40
|
+
|
|
41
|
+
Everything here is pure (no device I/O, no threads), so both layout modes
|
|
42
|
+
are unit-testable with fake capability dicts.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from collections.abc import Mapping
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from .device import DeckDevice
|
|
52
|
+
|
|
53
|
+
# classify_key result kinds
|
|
54
|
+
KEY_VIEW = "view"
|
|
55
|
+
KEY_PREV = "prev"
|
|
56
|
+
KEY_NEXT = "next"
|
|
57
|
+
KEY_SESSION = "session"
|
|
58
|
+
|
|
59
|
+
MODE_FULL = "full"
|
|
60
|
+
MODE_REDUCED = "reduced"
|
|
61
|
+
|
|
62
|
+
# FULL mode needs one dial for view cycling and one for paging.
|
|
63
|
+
_FULL_MODE_MIN_DIALS = 2
|
|
64
|
+
# REDUCED mode reserves 3 control keys and needs at least 1 session slot.
|
|
65
|
+
_REDUCED_MIN_KEYS = 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_capabilities(deck: DeckDevice) -> dict[str, Any]:
|
|
69
|
+
"""Build the capability dict `plan_layout` consumes, from a live device.
|
|
70
|
+
|
|
71
|
+
Pure with respect to the deck: only calls capability accessors (class
|
|
72
|
+
constants on every `streamdeck` model), no device I/O.
|
|
73
|
+
"""
|
|
74
|
+
rows, cols = deck.key_layout()
|
|
75
|
+
return {
|
|
76
|
+
"key_count": deck.key_count(),
|
|
77
|
+
"key_rows": rows,
|
|
78
|
+
"key_cols": cols,
|
|
79
|
+
"dial_count": deck.dial_count(),
|
|
80
|
+
"is_touch": deck.is_touch(),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class LayoutPlan:
|
|
86
|
+
"""The one decision object: which keys/controls this deck uses for what.
|
|
87
|
+
|
|
88
|
+
`session_slots` maps *slot position* (index into the current page's
|
|
89
|
+
session list, reading order) -> *physical key index*. In FULL mode it
|
|
90
|
+
is simply `range(key_count)`; in REDUCED mode it skips the reserved
|
|
91
|
+
control keys.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
mode: str # MODE_FULL | MODE_REDUCED
|
|
95
|
+
key_count: int
|
|
96
|
+
view_key: int | None # reserved control keys (REDUCED mode only)
|
|
97
|
+
prev_key: int | None
|
|
98
|
+
next_key: int | None
|
|
99
|
+
session_slots: tuple[int, ...]
|
|
100
|
+
sessions_per_page: int
|
|
101
|
+
use_dials: bool # attach dial callbacks (view/page cycling)?
|
|
102
|
+
use_strip: bool # paint the touch strip (status headline)?
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def plan_layout(caps: Mapping[str, Any]) -> LayoutPlan:
|
|
106
|
+
"""Choose the layout mode and key assignments for a deck's capabilities.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
caps: capability dict with `key_count`, `key_rows`, `key_cols`,
|
|
110
|
+
`dial_count`, `is_touch` (see `read_capabilities`).
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A `LayoutPlan`; never raises for a weird-but-real deck shape.
|
|
114
|
+
"""
|
|
115
|
+
key_count = int(caps["key_count"])
|
|
116
|
+
rows = int(caps["key_rows"])
|
|
117
|
+
cols = int(caps["key_cols"])
|
|
118
|
+
dial_count = int(caps["dial_count"])
|
|
119
|
+
is_touch = bool(caps["is_touch"])
|
|
120
|
+
|
|
121
|
+
if dial_count >= _FULL_MODE_MIN_DIALS and is_touch:
|
|
122
|
+
return LayoutPlan(
|
|
123
|
+
mode=MODE_FULL,
|
|
124
|
+
key_count=key_count,
|
|
125
|
+
view_key=None,
|
|
126
|
+
prev_key=None,
|
|
127
|
+
next_key=None,
|
|
128
|
+
session_slots=tuple(range(key_count)),
|
|
129
|
+
sessions_per_page=key_count,
|
|
130
|
+
use_dials=True,
|
|
131
|
+
use_strip=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
view_key = 0
|
|
135
|
+
prev_key = (rows - 1) * cols
|
|
136
|
+
next_key = rows * cols - 1
|
|
137
|
+
reserved = (view_key, prev_key, next_key)
|
|
138
|
+
degenerate = (
|
|
139
|
+
key_count < _REDUCED_MIN_KEYS
|
|
140
|
+
or len(set(reserved)) < 3
|
|
141
|
+
or any(index >= key_count for index in reserved)
|
|
142
|
+
)
|
|
143
|
+
if degenerate:
|
|
144
|
+
# Too few keys (or a grid where the corners collide) to spend three
|
|
145
|
+
# on controls: every key is a session tile, no view/page controls.
|
|
146
|
+
return LayoutPlan(
|
|
147
|
+
mode=MODE_REDUCED,
|
|
148
|
+
key_count=key_count,
|
|
149
|
+
view_key=None,
|
|
150
|
+
prev_key=None,
|
|
151
|
+
next_key=None,
|
|
152
|
+
session_slots=tuple(range(key_count)),
|
|
153
|
+
sessions_per_page=max(1, key_count),
|
|
154
|
+
use_dials=False,
|
|
155
|
+
use_strip=is_touch,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
session_slots = tuple(i for i in range(key_count) if i not in reserved)
|
|
159
|
+
return LayoutPlan(
|
|
160
|
+
mode=MODE_REDUCED,
|
|
161
|
+
key_count=key_count,
|
|
162
|
+
view_key=view_key,
|
|
163
|
+
prev_key=prev_key,
|
|
164
|
+
next_key=next_key,
|
|
165
|
+
session_slots=session_slots,
|
|
166
|
+
sessions_per_page=len(session_slots),
|
|
167
|
+
use_dials=False,
|
|
168
|
+
use_strip=is_touch,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def classify_key(plan: LayoutPlan, key: int) -> tuple[str, int | None]:
|
|
173
|
+
"""Map a physical key press to its role under `plan`.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
`(KEY_VIEW | KEY_PREV | KEY_NEXT, None)` for a reserved control
|
|
177
|
+
key, or `(KEY_SESSION, slot_position)` where `slot_position`
|
|
178
|
+
indexes into the current page's session list. A key outside the
|
|
179
|
+
plan entirely (shouldn't happen on real hardware) classifies as
|
|
180
|
+
`(KEY_SESSION, None)` -- callers treat a `None` slot as an empty
|
|
181
|
+
press and ignore it.
|
|
182
|
+
"""
|
|
183
|
+
if key == plan.view_key:
|
|
184
|
+
return (KEY_VIEW, None)
|
|
185
|
+
if key == plan.prev_key:
|
|
186
|
+
return (KEY_PREV, None)
|
|
187
|
+
if key == plan.next_key:
|
|
188
|
+
return (KEY_NEXT, None)
|
|
189
|
+
try:
|
|
190
|
+
return (KEY_SESSION, plan.session_slots.index(key))
|
|
191
|
+
except ValueError:
|
|
192
|
+
return (KEY_SESSION, None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def describe_plan(plan: LayoutPlan) -> str:
|
|
196
|
+
"""One-line human-readable summary for the bring-up log."""
|
|
197
|
+
if plan.mode == MODE_FULL:
|
|
198
|
+
return (
|
|
199
|
+
f"full layout: {plan.key_count} session keys/page, "
|
|
200
|
+
"dial0=view cycling, dial1=paging, strip=status"
|
|
201
|
+
)
|
|
202
|
+
if plan.view_key is None:
|
|
203
|
+
return (
|
|
204
|
+
f"reduced layout (degenerate grid): all {plan.key_count} keys are "
|
|
205
|
+
"session tiles, no view/page controls"
|
|
206
|
+
)
|
|
207
|
+
return (
|
|
208
|
+
f"reduced layout: {plan.sessions_per_page} session keys/page, "
|
|
209
|
+
f"key[{plan.view_key}]=VIEW (tap opens picker), key[{plan.prev_key}]=PREV, "
|
|
210
|
+
f"key[{plan.next_key}]=NEXT" + ("" if not plan.use_strip else ", strip=status")
|
|
211
|
+
)
|