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,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
@@ -0,0 +1,5 @@
1
+ """muxplex-deck sidecar: drive an Elgato Stream Deck+ against a muxplex server.
2
+
3
+ Shows muxplex tmux sessions on the deck's 8 keys and switches the active
4
+ session on key press. See README.md for setup and the config file format.
5
+ """
@@ -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