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
deck_probe/rendering.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pillow-based rendering helpers for the Stream Deck+ probe.
|
|
2
|
+
|
|
3
|
+
Every function here is a pure image generator: given a deck (used only for
|
|
4
|
+
its declared image formats/dimensions) it returns native image bytes ready
|
|
5
|
+
to hand to `StreamDeck.set_key_image` / `StreamDeck.set_touchscreen_image`.
|
|
6
|
+
No device I/O happens in this module -- that keeps rendering testable in
|
|
7
|
+
isolation and keeps the state machine in main.py free of drawing code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from PIL import ImageDraw, ImageFont
|
|
13
|
+
from StreamDeck.Devices.StreamDeck import StreamDeck
|
|
14
|
+
from StreamDeck.ImageHelpers import PILHelper
|
|
15
|
+
|
|
16
|
+
# Distinct, clearly-differentiable background colors, one per key index.
|
|
17
|
+
KEY_COLORS: tuple[str, ...] = (
|
|
18
|
+
"#1f77b4", # 0 blue
|
|
19
|
+
"#ff7f0e", # 1 orange
|
|
20
|
+
"#2ca02c", # 2 green
|
|
21
|
+
"#d62728", # 3 red
|
|
22
|
+
"#9467bd", # 4 purple
|
|
23
|
+
"#8c564b", # 5 brown
|
|
24
|
+
"#e377c2", # 6 pink
|
|
25
|
+
"#7f7f7f", # 7 gray
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# The touch strip is divided into one zone per dial: dial 0 shows live
|
|
29
|
+
# brightness (proves set_brightness), the rest show their tap/turn counters.
|
|
30
|
+
# Zone count is derived from the deck's dial_count() -- capability-driven,
|
|
31
|
+
# not hardcoded to the Plus's 4 dials.
|
|
32
|
+
BRIGHTNESS_ZONE_INDEX = 0
|
|
33
|
+
|
|
34
|
+
_LABEL_FONT_SIZE = 16
|
|
35
|
+
_VALUE_FONT_SIZE = 28
|
|
36
|
+
# Key labels scale with the key's pixel size: 120px keys (Plus) get the
|
|
37
|
+
# original 48px font; 72px keys (Original/MK2) get ~28px. Never assume
|
|
38
|
+
# a fixed key resolution.
|
|
39
|
+
_KEY_LABEL_FONT_RATIO = 0.4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def zone_labels(dial_count: int) -> tuple[str, ...]:
|
|
43
|
+
"""Touchscreen zone labels for a deck with `dial_count` dials."""
|
|
44
|
+
if dial_count <= 0:
|
|
45
|
+
return ()
|
|
46
|
+
return (
|
|
47
|
+
"D0 BRIGHTNESS",
|
|
48
|
+
*(f"D{i} COUNTER" for i in range(1, dial_count)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_key_image(
|
|
53
|
+
deck: StreamDeck, index: int, *, highlighted: bool = False
|
|
54
|
+
) -> bytes:
|
|
55
|
+
"""Render a key image labeled with its index, in a distinct color.
|
|
56
|
+
|
|
57
|
+
When `highlighted` is True, the key is rendered in inverted colors
|
|
58
|
+
(bright text on black) to give clear visual feedback while the physical
|
|
59
|
+
button is held down.
|
|
60
|
+
"""
|
|
61
|
+
if highlighted:
|
|
62
|
+
background, text_color = "#000000", "#ffffff"
|
|
63
|
+
else:
|
|
64
|
+
background, text_color = KEY_COLORS[index % len(KEY_COLORS)], "#000000"
|
|
65
|
+
|
|
66
|
+
image = PILHelper.create_key_image(deck, background=background)
|
|
67
|
+
draw = ImageDraw.Draw(image)
|
|
68
|
+
# Scale the label to the key's actual pixel size (72px on the 15-key
|
|
69
|
+
# Original/MK2, 96px on the Neo, 120px on the Plus).
|
|
70
|
+
font = ImageFont.load_default(
|
|
71
|
+
size=max(16, int(image.height * _KEY_LABEL_FONT_RATIO))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
label = str(index)
|
|
75
|
+
bbox = draw.textbbox((0, 0), label, font=font)
|
|
76
|
+
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
77
|
+
position = (
|
|
78
|
+
(image.width - text_w) / 2 - bbox[0],
|
|
79
|
+
(image.height - text_h) / 2 - bbox[1],
|
|
80
|
+
)
|
|
81
|
+
draw.text(position, label, fill=text_color, font=font)
|
|
82
|
+
|
|
83
|
+
return PILHelper.to_native_key_format(deck, image)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _draw_zone(
|
|
87
|
+
draw: ImageDraw.ImageDraw,
|
|
88
|
+
x_offset: int,
|
|
89
|
+
width: int,
|
|
90
|
+
height: int,
|
|
91
|
+
label: str,
|
|
92
|
+
value: str,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Draw a zone's label + value, centered within [x_offset, x_offset + width)."""
|
|
95
|
+
label_font = ImageFont.load_default(size=_LABEL_FONT_SIZE)
|
|
96
|
+
value_font = ImageFont.load_default(size=_VALUE_FONT_SIZE)
|
|
97
|
+
|
|
98
|
+
label_bbox = draw.textbbox((0, 0), label, font=label_font)
|
|
99
|
+
label_x = x_offset + (width - (label_bbox[2] - label_bbox[0])) / 2
|
|
100
|
+
draw.text((label_x, 8), label, fill="#aaaaaa", font=label_font)
|
|
101
|
+
|
|
102
|
+
value_bbox = draw.textbbox((0, 0), value, font=value_font)
|
|
103
|
+
value_x = x_offset + (width - (value_bbox[2] - value_bbox[0])) / 2
|
|
104
|
+
draw.text(
|
|
105
|
+
(value_x, height - _VALUE_FONT_SIZE - 12),
|
|
106
|
+
value,
|
|
107
|
+
fill="#ffffff",
|
|
108
|
+
font=value_font,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_touchscreen_full(
|
|
113
|
+
deck: StreamDeck,
|
|
114
|
+
*,
|
|
115
|
+
brightness_percent: int,
|
|
116
|
+
counters: list[int],
|
|
117
|
+
marker_x: int | None = None,
|
|
118
|
+
) -> bytes:
|
|
119
|
+
"""Render the full touch strip: one labeled zone per dial plus an optional tap marker.
|
|
120
|
+
|
|
121
|
+
Only meaningful on touchscreen decks; callers gate on `is_touch()`.
|
|
122
|
+
Zone count follows the deck's dial count (Plus: 4).
|
|
123
|
+
"""
|
|
124
|
+
image = PILHelper.create_touchscreen_image(deck, background="black")
|
|
125
|
+
draw = ImageDraw.Draw(image)
|
|
126
|
+
labels = zone_labels(deck.dial_count())
|
|
127
|
+
zone_width = image.width // max(len(labels), 1)
|
|
128
|
+
|
|
129
|
+
values = [f"{brightness_percent}%", *[str(c) for c in counters]]
|
|
130
|
+
for zone_index, (label, value) in enumerate(zip(labels, values)):
|
|
131
|
+
x_offset = zone_index * zone_width
|
|
132
|
+
if zone_index > 0:
|
|
133
|
+
draw.line(
|
|
134
|
+
[(x_offset, 0), (x_offset, image.height)], fill="#444444", width=1
|
|
135
|
+
)
|
|
136
|
+
_draw_zone(draw, x_offset, zone_width, image.height, label, value)
|
|
137
|
+
|
|
138
|
+
if marker_x is not None:
|
|
139
|
+
draw.line([(marker_x, 0), (marker_x, image.height)], fill="#ffff00", width=3)
|
|
140
|
+
|
|
141
|
+
return PILHelper.to_native_touchscreen_format(deck, image)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_touchscreen_zone(
|
|
145
|
+
deck: StreamDeck, zone_index: int, label: str, value: str
|
|
146
|
+
) -> tuple[bytes, int, int, int, int]:
|
|
147
|
+
"""Render a single zone as a standalone image for a partial touchscreen update.
|
|
148
|
+
|
|
149
|
+
Returns (native_image_bytes, x_pos, y_pos, width, height) ready to pass
|
|
150
|
+
directly to `StreamDeck.set_touchscreen_image` for the region only --
|
|
151
|
+
proving the device's partial-update support rather than repainting the
|
|
152
|
+
whole 800x100 strip on every dial tick.
|
|
153
|
+
"""
|
|
154
|
+
full_width, full_height = deck.touchscreen_image_format()["size"]
|
|
155
|
+
zone_width = full_width // max(deck.dial_count(), 1)
|
|
156
|
+
x_pos = zone_index * zone_width
|
|
157
|
+
|
|
158
|
+
zone_image = PILHelper.create_touchscreen_image(deck, background="black")
|
|
159
|
+
zone_image = zone_image.crop((0, 0, zone_width, full_height))
|
|
160
|
+
draw = ImageDraw.Draw(zone_image)
|
|
161
|
+
if zone_index > 0:
|
|
162
|
+
draw.line([(0, 0), (0, full_height)], fill="#444444", width=1)
|
|
163
|
+
_draw_zone(draw, 0, zone_width, full_height, label, value)
|
|
164
|
+
|
|
165
|
+
native = PILHelper.to_native_touchscreen_format(deck, zone_image)
|
|
166
|
+
return native, x_pos, 0, zone_width, full_height
|
muxplex_deck/__init__.py
ADDED
muxplex_deck/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Allow running muxplex-deck as: python -m muxplex_deck
|
|
2
|
+
|
|
3
|
+
Needed as the `python -m` fallback the service-management code (launchd's
|
|
4
|
+
ProgramArguments, systemd's ExecStart) resolves to when neither the
|
|
5
|
+
`muxplex-deck` console-script nor the `~/.local/bin/muxplex-deck` symlink
|
|
6
|
+
can be found on PATH.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from muxplex_deck.cli import main
|
|
10
|
+
|
|
11
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Attention-first session ordering: surface what's most likely urgent on page 1.
|
|
2
|
+
|
|
3
|
+
Reorders an already view-resolved (and possibly already `sort_order`-sorted)
|
|
4
|
+
session list into three stable tiers:
|
|
5
|
+
|
|
6
|
+
1. Sessions needing attention (`Bell.needs_attention`), newest bell fire first.
|
|
7
|
+
2. The active session, if it isn't already in tier 1.
|
|
8
|
+
3. Everything else, by `last_activity_at` descending (most recently active
|
|
9
|
+
first, `None` last) when the server exposes that field -- otherwise the
|
|
10
|
+
incoming base order is preserved unchanged.
|
|
11
|
+
|
|
12
|
+
Applied to the view-resolved list *before* paging (`interaction.Pager`), so
|
|
13
|
+
hot sessions land on the first page regardless of how many pages the view
|
|
14
|
+
has. This is sidecar-only client-side reordering -- muxplex's own
|
|
15
|
+
`sort_order` setting (alphabetical/manual) is untouched server-side; a
|
|
16
|
+
sidecar configured with `"sort": "server"` skips this module entirely and
|
|
17
|
+
shows exactly what `views.resolve_view` returned.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from muxplex_client import Session
|
|
23
|
+
|
|
24
|
+
NEG_INF = float("-inf")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def activity_available(sessions: list[Session]) -> bool:
|
|
28
|
+
"""True if at least one session in `sessions` carries a `last_activity_at`.
|
|
29
|
+
|
|
30
|
+
Servers predating the `feat/session-activity` branch never populate this
|
|
31
|
+
field (every session's value is `None`) -- callers use this to decide
|
|
32
|
+
whether to log the one-time "server doesn't expose this yet" notice and
|
|
33
|
+
to select the tier-3 fallback ordering.
|
|
34
|
+
"""
|
|
35
|
+
return any(s.last_activity_at is not None for s in sessions)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def apply_attention_sort(
|
|
39
|
+
sessions: list[Session],
|
|
40
|
+
active_session: str | None,
|
|
41
|
+
*,
|
|
42
|
+
activity_available: bool,
|
|
43
|
+
) -> list[Session]:
|
|
44
|
+
"""Reorder `sessions` (view-resolved, base-ordered) attention-first.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
sessions: the view-resolved session list, in base order (whatever
|
|
48
|
+
`views.resolve_view` produced -- alphabetical or server order).
|
|
49
|
+
active_session: name of the currently active session, or None.
|
|
50
|
+
activity_available: whether this connection's server exposes
|
|
51
|
+
`last_activity_at` (see `activity_available()` above) -- pass
|
|
52
|
+
the result of checking the *full* unfiltered session list, not
|
|
53
|
+
just this view, since a view might legitimately contain zero
|
|
54
|
+
sessions with recorded activity even on a server that supports
|
|
55
|
+
the field.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A new list in tier 1 -> tier 2 -> tier 3 order. Sessions not present
|
|
59
|
+
in `sessions` (e.g. an active session outside this view) are not
|
|
60
|
+
added -- this function only reorders what's already there.
|
|
61
|
+
"""
|
|
62
|
+
tier1 = [s for s in sessions if s.bell.needs_attention]
|
|
63
|
+
tier1.sort(key=lambda s: s.bell.last_fired_at or NEG_INF, reverse=True)
|
|
64
|
+
tier1_names = {s.name for s in tier1}
|
|
65
|
+
|
|
66
|
+
tier2 = [
|
|
67
|
+
s for s in sessions if s.name == active_session and s.name not in tier1_names
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
remaining = [
|
|
71
|
+
s for s in sessions if s.name not in tier1_names and s.name != active_session
|
|
72
|
+
]
|
|
73
|
+
if activity_available:
|
|
74
|
+
with_activity = [s for s in remaining if s.last_activity_at is not None]
|
|
75
|
+
without_activity = [s for s in remaining if s.last_activity_at is None]
|
|
76
|
+
with_activity.sort(key=lambda s: s.last_activity_at, reverse=True) # type: ignore[arg-type,return-value]
|
|
77
|
+
tier3 = with_activity + without_activity
|
|
78
|
+
else:
|
|
79
|
+
tier3 = remaining
|
|
80
|
+
|
|
81
|
+
return tier1 + tier2 + tier3
|