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.
@@ -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
+ )