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,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))
|