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,440 @@
1
+ """Pillow-based rendering for the muxplex sidecar.
2
+
3
+ Adapted from `deck_probe/rendering.py`'s proven patterns (native image
4
+ conversion via `PILHelper`, explicit touch-strip region size) but drawing
5
+ session mini-terminal previews and server status instead of probe
6
+ diagnostics. No device I/O happens here -- these are pure image generators,
7
+ kept separate from `main.py`'s state machine so rendering stays testable in
8
+ isolation.
9
+
10
+ Functions here accept the `DeckDevice` protocol (real hardware or the
11
+ emulator), not the concrete `streamdeck` library type -- but `PILHelper`'s
12
+ own functions are typed against that concrete class. `PILHelper` only ever
13
+ calls `key_image_format()` / `touchscreen_image_format()` on what we pass
14
+ it, both of which `DeckDevice` guarantees, so the `cast()` calls below are
15
+ purely to satisfy the type checker across that library boundary; they
16
+ change no runtime behavior.
17
+
18
+ Key preview design (v1): each occupied key shows a cropped mini terminal --
19
+ the bottom-left corner of the session's live pane snapshot -- with the
20
+ session name, active/attention status border(s), layered on top so
21
+ identity/status stay legible regardless of what's scrolling underneath.
22
+ ANSI color escapes are stripped (plain text only); see `_strip_ansi` and
23
+ the module-level fidelity note below `_preview_lines`.
24
+
25
+ Status borders (v2, real-hardware feedback): active-session and
26
+ needs-attention are shown as colored rectangular borders rather than a
27
+ background fill or a small dot, using the exact brand colors from
28
+ muxplex's own frontend (`frontend/style.css`) so the deck's language
29
+ matches the PWA's: cyan `#00D9F5` for the active session, amber `#F1A640`
30
+ for needs-attention. When both apply to the same key, two concentric rings
31
+ are drawn (amber outer, cyan inner) so neither state is lost -- see
32
+ `render_session_key`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from typing import cast
39
+
40
+ from PIL import Image, ImageDraw, ImageFont
41
+ from StreamDeck.Devices.StreamDeck import StreamDeck
42
+ from StreamDeck.ImageHelpers import PILHelper
43
+
44
+ from muxplex_client import Session
45
+ from .device import DeckDevice
46
+
47
+ _KEY_LABEL_FONT_SIZE = 16
48
+ _STRIP_FONT_SIZE = 22
49
+
50
+ _EMPTY_BG = "#000000" # blank -- no session in this slot
51
+ _TEXT_COLOR = "#ffffff"
52
+
53
+ MAX_SESSION_LABEL_CHARS = 10
54
+
55
+ # --- Mini terminal preview -------------------------------------------------
56
+
57
+ _PREVIEW_BG = "#0a0a0a" # near-black -- the preview's own background
58
+ _PREVIEW_TEXT_COLOR = "#a8a8a8" # light gray -- low-contrast so name/badges pop
59
+ # v3 (real-hardware feedback: v1's size-8 was illegible, v2's size-16 too
60
+ # zoomed-in) -- split the difference at ~12, with line/column counts scaled to
61
+ # match so the crop still fits under the banner within the 120x120 key. Still
62
+ # "recognize your session by its shape", not "read the text" -- see the fidelity
63
+ # note in `_preview_lines` below.
64
+ _PREVIEW_FONT_SIZE = 11
65
+ _PREVIEW_LINE_HEIGHT = 13
66
+ # Approximate advance width of one monospace character at _PREVIEW_FONT_SIZE,
67
+ # used to derive how many columns fit the key's real width (see
68
+ # `_preview_geometry`). 5.5 is anchored so a 120px key yields exactly the 21
69
+ # columns the hardware-verified Stream Deck+ path always used.
70
+ _PREVIEW_CHAR_WIDTH = 5.5
71
+ _PREVIEW_LEFT_MARGIN = 3
72
+
73
+ _BANNER_HEIGHT = 20 # translucent strip behind the session name, top of key
74
+ _BANNER_FILL = (0, 0, 0, 170) # RGBA -- ~two-thirds-opaque black
75
+
76
+ # Brand colors lifted verbatim from muxplex's frontend/style.css so the
77
+ # deck's status language matches the PWA's exactly.
78
+ _ACTIVE_BORDER_COLOR = "#00D9F5" # muxplex brand cyan -- active session
79
+ _ATTENTION_BORDER_COLOR = "#F1A640" # muxplex brand amber -- needs attention
80
+ _BORDER_WIDTH = 4 # single-state border thickness
81
+ _DUAL_RING_WIDTH = 3 # each ring's thickness when both states apply at once
82
+
83
+ # Matches ANSI CSI sequences (colors, cursor moves, etc.) -- v1 strips all
84
+ # color/formatting rather than rendering it; see module docstring. Covers
85
+ # what `tmux capture-pane -e` actually emits (SGR color codes); a fancier
86
+ # fidelity pass (the PWA's own SGR parser, app.js) is a documented follow-up,
87
+ # not attempted here.
88
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
89
+
90
+
91
+ def _truncate(name: str, limit: int = MAX_SESSION_LABEL_CHARS) -> str:
92
+ if len(name) <= limit:
93
+ return name
94
+ return name[: limit - 1] + "\u2026"
95
+
96
+
97
+ def _strip_ansi(text: str) -> str:
98
+ return _ANSI_ESCAPE_RE.sub("", text)
99
+
100
+
101
+ def _preview_geometry(width: int, height: int) -> tuple[int, int]:
102
+ """(lines, columns) of preview crop that fit a key of the given real size.
103
+
104
+ Derived from the deck's actual `key_image_format()['size']` at render
105
+ time -- never assumes 120px. The formulas are anchored so the
106
+ hardware-verified 120x120 Stream Deck+ path yields exactly the 8 lines
107
+ x 21 columns the previous fixed constants produced (no Deck+ regression),
108
+ while a 72x72 Original/MK2 key scales down (4 lines x 12 columns)
109
+ instead of overflowing the key.
110
+ """
111
+ lines = max(1, (height - _BANNER_HEIGHT - 2) // _PREVIEW_LINE_HEIGHT + 1)
112
+ columns = max(1, int((width - _PREVIEW_LEFT_MARGIN) / _PREVIEW_CHAR_WIDTH))
113
+ return lines, columns
114
+
115
+
116
+ def _preview_lines(snapshot: str, max_lines: int, max_columns: int) -> list[str]:
117
+ """Bottom-left crop of a session's snapshot, ready to draw line-by-line.
118
+
119
+ Strips ANSI escapes, trims trailing blank lines (the snapshot is a
120
+ fixed-height `tmux capture-pane` and commonly ends with several once a
121
+ session's actual output is shorter than the capture window), then keeps
122
+ the last `max_lines` lines and the first `max_columns` columns of each
123
+ -- bottom-left, chosen over the PWA's bottom-anchored *full-width*
124
+ preview because a square key has no width to spare. `max_lines` /
125
+ `max_columns` come from `_preview_geometry` (the key's real pixels).
126
+
127
+ Honest fidelity note: at ~5px/character this crop is "recognize your
128
+ session by its shape and color" (well, grayscale in v1), not "read the
129
+ text" -- the same tradeoff the PWA's own thumbnails make, just smaller.
130
+ """
131
+ plain = _strip_ansi(snapshot)
132
+ lines = plain.splitlines()
133
+ while lines and not lines[-1].strip():
134
+ lines.pop()
135
+ tail = lines[-max_lines:] if max_lines else []
136
+ return [line[:max_columns] for line in tail]
137
+
138
+
139
+ def _fit_label(
140
+ draw: ImageDraw.ImageDraw,
141
+ text: str,
142
+ font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
143
+ max_width: float,
144
+ ) -> str:
145
+ """Shorten `text` (with an ellipsis) until it fits `max_width` pixels.
146
+
147
+ Complements the fixed character-count `_truncate` caps: those were
148
+ tuned on 120px keys, so on smaller keys (72px Original) a 10-char name
149
+ could still overflow. Measures the real rendered width instead.
150
+ """
151
+ if draw.textlength(text, font=font) <= max_width:
152
+ return text
153
+ while text and draw.textlength(text + "\u2026", font=font) > max_width:
154
+ text = text[:-1]
155
+ return text + "\u2026"
156
+
157
+
158
+ def _draw_border(
159
+ draw: ImageDraw.ImageDraw,
160
+ image: Image.Image,
161
+ color: str,
162
+ width: int,
163
+ *,
164
+ inset: int = 0,
165
+ ) -> None:
166
+ """Draw a `width`-px rectangular outline, `inset` px in from the key's edge.
167
+
168
+ Used for both the single-state active/attention border and, when both
169
+ states apply, two concentric rings (an `inset=0` outer ring and an
170
+ `inset=<outer width>` inner ring) -- see `render_session_key`.
171
+ """
172
+ for w in range(width):
173
+ offset = inset + w
174
+ draw.rectangle(
175
+ [(offset, offset), (image.width - 1 - offset, image.height - 1 - offset)],
176
+ outline=color,
177
+ )
178
+
179
+
180
+ def render_session_key(deck: DeckDevice, session: Session, *, active: bool) -> bytes:
181
+ """Render one key: mini terminal preview, name banner, status border(s).
182
+
183
+ Layering (bottom to top): near-black background -> preview text ->
184
+ translucent name banner + name -> status border(s) (cyan for active,
185
+ amber for needs-attention, both as concentric rings if both apply). The
186
+ banner and border stay legible no matter what's scrolling in the
187
+ preview beneath them.
188
+ """
189
+ image = PILHelper.create_key_image(cast(StreamDeck, deck), background=_PREVIEW_BG)
190
+ draw = ImageDraw.Draw(image)
191
+ preview_font = ImageFont.load_default(size=_PREVIEW_FONT_SIZE)
192
+
193
+ max_lines, max_columns = _preview_geometry(image.width, image.height)
194
+ lines = _preview_lines(session.snapshot, max_lines, max_columns)
195
+ base_y = image.height - len(lines) * _PREVIEW_LINE_HEIGHT - 2
196
+ for row, line in enumerate(lines):
197
+ if not line:
198
+ continue
199
+ draw.text(
200
+ (_PREVIEW_LEFT_MARGIN, base_y + row * _PREVIEW_LINE_HEIGHT),
201
+ line,
202
+ fill=_PREVIEW_TEXT_COLOR,
203
+ font=preview_font,
204
+ )
205
+
206
+ # Translucent name banner -- needs an RGBA overlay composited onto the
207
+ # (opaque RGB) preview, then flattened back to RGB for the native format.
208
+ overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
209
+ ImageDraw.Draw(overlay).rectangle(
210
+ [(0, 0), (image.width, _BANNER_HEIGHT)], fill=_BANNER_FILL
211
+ )
212
+ image = Image.alpha_composite(image.convert("RGBA"), overlay).convert("RGB")
213
+ draw = ImageDraw.Draw(image)
214
+
215
+ name_font = ImageFont.load_default(size=_KEY_LABEL_FONT_SIZE)
216
+ label = _fit_label(draw, _truncate(session.name), name_font, image.width - 4)
217
+ bbox = draw.textbbox((0, 0), label, font=name_font)
218
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
219
+ name_position = (
220
+ (image.width - text_w) / 2 - bbox[0],
221
+ (_BANNER_HEIGHT - text_h) / 2 - bbox[1],
222
+ )
223
+ draw.text(name_position, label, fill=_TEXT_COLOR, font=name_font)
224
+
225
+ needs_attention = session.bell.needs_attention
226
+ if active and needs_attention:
227
+ # Both states at once: amber outer ring + cyan inner ring, each
228
+ # legible on its own -- neither status gets silently dropped.
229
+ _draw_border(draw, image, _ATTENTION_BORDER_COLOR, _DUAL_RING_WIDTH)
230
+ _draw_border(
231
+ draw,
232
+ image,
233
+ _ACTIVE_BORDER_COLOR,
234
+ _DUAL_RING_WIDTH,
235
+ inset=_DUAL_RING_WIDTH,
236
+ )
237
+ elif active:
238
+ _draw_border(draw, image, _ACTIVE_BORDER_COLOR, _BORDER_WIDTH)
239
+ elif needs_attention:
240
+ _draw_border(draw, image, _ATTENTION_BORDER_COLOR, _BORDER_WIDTH)
241
+
242
+ return PILHelper.to_native_key_format(cast(StreamDeck, deck), image)
243
+
244
+
245
+ def render_empty_key(deck: DeckDevice) -> bytes:
246
+ """Render a blank (unused) key slot -- no session mapped to it."""
247
+ image = PILHelper.create_key_image(cast(StreamDeck, deck), background=_EMPTY_BG)
248
+ return PILHelper.to_native_key_format(cast(StreamDeck, deck), image)
249
+
250
+
251
+ _PICKER_BG = "#101036" # dark indigo -- visually distinct from the session preview bg
252
+ _PICKER_TEXT_COLOR = "#ffffff"
253
+ _PICKER_FONT_SIZE = 20
254
+ _PICKER_LABEL_CHARS = 12
255
+ _PICKER_CURRENT_BORDER_COLOR = _ACTIVE_BORDER_COLOR # same cyan as the active session
256
+ _PICKER_CURRENT_BORDER_WIDTH = _BORDER_WIDTH
257
+
258
+
259
+ def render_picker_key(deck: DeckDevice, label: str, *, current: bool) -> bytes:
260
+ """Render one picker-mode key: a centered label on a distinct background.
261
+
262
+ Shared by both the view picker (dial 0 press) and the page picker
263
+ (dial 1 press) -- `label` is a view name or a page number as a string.
264
+ `current` draws the same cyan border used for the active session,
265
+ marking whichever option matches today's actual active view/page.
266
+ """
267
+ image = PILHelper.create_key_image(cast(StreamDeck, deck), background=_PICKER_BG)
268
+ draw = ImageDraw.Draw(image)
269
+ font = ImageFont.load_default(size=_PICKER_FONT_SIZE)
270
+ text = _fit_label(
271
+ draw, _truncate(label, limit=_PICKER_LABEL_CHARS), font, image.width - 4
272
+ )
273
+ bbox = draw.textbbox((0, 0), text, font=font)
274
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
275
+ position = (
276
+ (image.width - text_w) / 2 - bbox[0],
277
+ (image.height - text_h) / 2 - bbox[1],
278
+ )
279
+ draw.text(position, text, fill=_PICKER_TEXT_COLOR, font=font)
280
+ if current:
281
+ _draw_border(
282
+ draw, image, _PICKER_CURRENT_BORDER_COLOR, _PICKER_CURRENT_BORDER_WIDTH
283
+ )
284
+ return PILHelper.to_native_key_format(cast(StreamDeck, deck), image)
285
+
286
+
287
+ # --- Reserved control keys (dial-less decks) --------------------------------
288
+ #
289
+ # On decks with no dials/touch strip (Original/MK2/XL/Mini), three keys are
290
+ # reserved for the roles the dials and strip played -- see `layout.py`. They
291
+ # reuse the picker's indigo background so "control chrome" is visually
292
+ # distinct from session tiles, and their labels are ASCII-only: the default
293
+ # PIL font has no glyphs for arrows and renders .notdef boxes instead
294
+ # (proven on real hardware with U+2192).
295
+
296
+ _CONTROL_BG = _PICKER_BG
297
+ _CONTROL_TEXT_COLOR = "#ffffff"
298
+ _CONTROL_DIM_COLOR = "#8888aa"
299
+ _CONTROL_TITLE_FONT_SIZE = 11
300
+ _CONTROL_BODY_FONT_SIZE = 15
301
+ _CONTROL_FOOTER_FONT_SIZE = 11
302
+
303
+
304
+ def render_control_key(
305
+ deck: DeckDevice, *, title: str, body: str, footer: str = ""
306
+ ) -> bytes:
307
+ """Render one reserved control key (VIEW / PREV / NEXT).
308
+
309
+ Three stacked rows on the control background, each fit to the key's
310
+ *real* width (72px Original and 120px Plus both work):
311
+ - `title`: small dim caption at the top (e.g. "VIEW").
312
+ - `body`: the prominent centered label (view name, "< PREV", "NEXT >").
313
+ - `footer`: small dim line at the bottom (server label, or "pN/M").
314
+ """
315
+ image = PILHelper.create_key_image(cast(StreamDeck, deck), background=_CONTROL_BG)
316
+ draw = ImageDraw.Draw(image)
317
+ max_width = image.width - 4
318
+
319
+ if title:
320
+ font = ImageFont.load_default(size=_CONTROL_TITLE_FONT_SIZE)
321
+ text = _fit_label(draw, title, font, max_width)
322
+ width = draw.textlength(text, font=font)
323
+ draw.text(
324
+ ((image.width - width) / 2, 2), text, fill=_CONTROL_DIM_COLOR, font=font
325
+ )
326
+
327
+ body_font = ImageFont.load_default(size=_CONTROL_BODY_FONT_SIZE)
328
+ text = _fit_label(draw, body, body_font, max_width)
329
+ bbox = draw.textbbox((0, 0), text, font=body_font)
330
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
331
+ draw.text(
332
+ ((image.width - text_w) / 2 - bbox[0], (image.height - text_h) / 2 - bbox[1]),
333
+ text,
334
+ fill=_CONTROL_TEXT_COLOR,
335
+ font=body_font,
336
+ )
337
+
338
+ if footer:
339
+ font = ImageFont.load_default(size=_CONTROL_FOOTER_FONT_SIZE)
340
+ text = _fit_label(draw, footer, font, max_width)
341
+ width = draw.textlength(text, font=font)
342
+ draw.text(
343
+ (
344
+ (image.width - width) / 2,
345
+ image.height - _CONTROL_FOOTER_FONT_SIZE - 4,
346
+ ),
347
+ text,
348
+ fill=_CONTROL_DIM_COLOR,
349
+ font=font,
350
+ )
351
+
352
+ return PILHelper.to_native_key_format(cast(StreamDeck, deck), image)
353
+
354
+
355
+ _STATUS_KEY_FONT_SIZE = 11
356
+ _STATUS_KEY_LINE_HEIGHT = 13
357
+ _STATUS_KEY_MARGIN = 3
358
+
359
+
360
+ def render_status_key(deck: DeckDevice, message: str) -> bytes:
361
+ """Render a status message word-wrapped onto a single key.
362
+
363
+ Decks without a touch strip have nowhere else to show AUTH FAILED /
364
+ UNREACHABLE states, so the message is wrapped onto one key (the VIEW
365
+ key's position) at small size -- honest signal over polish; the log
366
+ carries the full detail.
367
+ """
368
+ image = PILHelper.create_key_image(cast(StreamDeck, deck), background=_EMPTY_BG)
369
+ draw = ImageDraw.Draw(image)
370
+ font = ImageFont.load_default(size=_STATUS_KEY_FONT_SIZE)
371
+ max_width = image.width - 2 * _STATUS_KEY_MARGIN
372
+ max_lines = max(
373
+ 1, (image.height - 2 * _STATUS_KEY_MARGIN) // _STATUS_KEY_LINE_HEIGHT
374
+ )
375
+
376
+ lines: list[str] = []
377
+ current = ""
378
+ for word in message.split():
379
+ candidate = f"{current} {word}".strip()
380
+ if not current or draw.textlength(candidate, font=font) <= max_width:
381
+ current = candidate
382
+ else:
383
+ lines.append(current)
384
+ current = word
385
+ if len(lines) >= max_lines:
386
+ break
387
+ if current and len(lines) < max_lines:
388
+ lines.append(current)
389
+
390
+ for row, line in enumerate(lines):
391
+ draw.text(
392
+ (_STATUS_KEY_MARGIN, _STATUS_KEY_MARGIN + row * _STATUS_KEY_LINE_HEIGHT),
393
+ _fit_label(draw, line, font, max_width),
394
+ fill=_TEXT_COLOR,
395
+ font=font,
396
+ )
397
+
398
+ return PILHelper.to_native_key_format(cast(StreamDeck, deck), image)
399
+
400
+
401
+ def _paint_full_touchscreen(deck: DeckDevice, image_bytes: bytes) -> None:
402
+ """Paint the entire touch strip.
403
+
404
+ The library requires explicit region dimensions for a non-empty image --
405
+ width/height default to 0, which raises `IndexError: Invalid draw width
406
+ 0.` This was discovered and fixed in deck_probe/events.py; the same
407
+ requirement applies here.
408
+ """
409
+ width, height = deck.touchscreen_image_format()["size"]
410
+ deck.set_touchscreen_image(image_bytes, 0, 0, width, height)
411
+
412
+
413
+ def render_status_strip(deck: DeckDevice, message: str) -> bytes:
414
+ """Render the touch strip as a single centered status line."""
415
+ image = PILHelper.create_touchscreen_image(
416
+ cast(StreamDeck, deck), background="black"
417
+ )
418
+ draw = ImageDraw.Draw(image)
419
+ font = ImageFont.load_default(size=_STRIP_FONT_SIZE)
420
+
421
+ bbox = draw.textbbox((0, 0), message, font=font)
422
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
423
+ position = (
424
+ (image.width - text_w) / 2 - bbox[0],
425
+ (image.height - text_h) / 2 - bbox[1],
426
+ )
427
+ draw.text(position, message, fill="#ffffff", font=font)
428
+
429
+ return PILHelper.to_native_touchscreen_format(cast(StreamDeck, deck), image)
430
+
431
+
432
+ def paint_status_strip(deck: DeckDevice, message: str) -> None:
433
+ """Render and paint the status strip in one call."""
434
+ _paint_full_touchscreen(deck, render_status_strip(deck, message))
435
+
436
+
437
+ def paint_blank_keys(deck: DeckDevice) -> None:
438
+ """Blank every key -- used before a status-only strip message is shown."""
439
+ for index in range(deck.key_count()):
440
+ deck.set_key_image(index, render_empty_key(deck))