plexi-sdk 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.
plexi_sdk/_types.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional
5
+
6
+
7
+ class CapabilityDeniedError(RuntimeError):
8
+ """Raised when the host rejects a brokered call because the app's manifest
9
+ didn't declare the required capability. Distinct from generic RuntimeError
10
+ so apps can catch the gate-denial path explicitly."""
11
+
12
+
13
+ # ── Video substrate (#345) types ──────────────────────────────────────────────
14
+
15
+ @dataclass
16
+ class VideoHandle:
17
+ """Result of `Emitter.open_video`. `handle_id` is opaque — pass it back to
18
+ `set_video_state` and `close_video`. The associated `Pipe` delivers
19
+ decoded RGBA8 frames (one frame per `pipe.read_frame()`) of length
20
+ `width * height * 4`."""
21
+ handle_id: int
22
+ width: int
23
+ height: int
24
+ fps: float
25
+ duration_ms: int
26
+ pipe: "object" # Pipe — avoid circular import; typed as object here
27
+
28
+
29
+ # ── Typed draw command dataclasses (#627) ─────────────────────────────────────
30
+
31
+ _VALID_TEXT_ALIGN = {"top_left", "center", "top_center", "right"}
32
+
33
+
34
+ @dataclass
35
+ class RectCommand:
36
+ """Typed constructor for ctx.rect(). Validates geometry at construction."""
37
+ x: float
38
+ y: float
39
+ w: float
40
+ h: float
41
+ fill: str
42
+ radius: float = 0.0
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.w < 0 or self.h < 0:
46
+ raise ValueError(f"RectCommand: w and h must be non-negative, got w={self.w}, h={self.h}")
47
+ if not self.fill.startswith("#"):
48
+ raise ValueError(f"RectCommand: fill must be a hex color string (e.g. '#1e1e2e'), got {self.fill!r}")
49
+
50
+
51
+ @dataclass
52
+ class TextCommand:
53
+ """Typed constructor for ctx.text(). Validates align at construction."""
54
+ x: float
55
+ y: float
56
+ text: str
57
+ size: float
58
+ color: str
59
+ monospace: bool = False
60
+ bold: bool = False
61
+ align: str = "top_left"
62
+ max_width: Optional[float] = None
63
+ elide: bool = True
64
+ selectable: bool = False
65
+
66
+ def __post_init__(self) -> None:
67
+ if self.align not in _VALID_TEXT_ALIGN:
68
+ raise ValueError(
69
+ f"TextCommand: align must be one of {sorted(_VALID_TEXT_ALIGN)}, got {self.align!r}"
70
+ )
71
+ if self.size <= 0:
72
+ raise ValueError(f"TextCommand: size must be positive, got {self.size}")
73
+
74
+
75
+ @dataclass
76
+ class BadgeCommand:
77
+ """Typed constructor for ctx.badge()."""
78
+ x: float
79
+ y_center: float
80
+ label: str
81
+ fill: str = "#89b4fa" # ACCENT
82
+ fg: str = "#1e1e2e" # BG
83
+ font_size: float = 11.0
84
+ radius: float = 8.0
85
+
86
+ def __post_init__(self) -> None:
87
+ if self.font_size <= 0:
88
+ raise ValueError(f"BadgeCommand: font_size must be positive, got {self.font_size}")
89
+ if self.radius < 0:
90
+ raise ValueError(f"BadgeCommand: radius must be non-negative, got {self.radius}")
91
+
92
+
93
+ @dataclass
94
+ class TextInputSpec:
95
+ """Typed constructor for ctx.text_input()."""
96
+ id: str
97
+ x: float
98
+ y: float
99
+ w: float
100
+ placeholder: str = ""
101
+ multiline: bool = False
102
+ h: float = 24.0
103
+
104
+ def __post_init__(self) -> None:
105
+ if not self.id:
106
+ raise ValueError("TextInputSpec: id must be a non-empty string")
107
+ if self.w <= 0:
108
+ raise ValueError(f"TextInputSpec: w must be positive, got {self.w}")
109
+
110
+
111
+ @dataclass
112
+ class ShortcutPair:
113
+ """Typed constructor for one entry in ctx.shortcuts() pairs."""
114
+ keys: List[str]
115
+ description: str = ""
116
+
117
+ def __post_init__(self) -> None:
118
+ if not self.keys:
119
+ raise ValueError("ShortcutPair: keys must be a non-empty list")
120
+
121
+
122
+ @dataclass
123
+ class NotifyOption:
124
+ """Typed constructor for one option in ctx.notify_choice()."""
125
+ label: str
126
+ value: Optional[str] = None
127
+ shortcut: Optional[str] = None
128
+
129
+ def __post_init__(self) -> None:
130
+ if not self.label:
131
+ raise ValueError("NotifyOption: label must be a non-empty string")
132
+
133
+ def to_dict(self) -> dict:
134
+ d: dict = {"label": self.label}
135
+ if self.value is not None:
136
+ d["value"] = self.value
137
+ if self.shortcut is not None:
138
+ d["shortcut"] = self.shortcut
139
+ return d
plexi_sdk/midi.py ADDED
@@ -0,0 +1,222 @@
1
+ """plexi_sdk.midi — MIDI 1.0 message constructors + decoders.
2
+
3
+ Apps construct MIDI byte streams with these helpers and pass them to
4
+ `emit.send_midi(port_id, bytes)`. Apps decode incoming pipe frames with
5
+ `decode(bytes)` to get a structured `MidiMessage`.
6
+
7
+ Spec reference: MIDI 1.0 detailed specification (channel-voice 0x80..=0xEF,
8
+ system real-time 0xF8..=0xFF). SysEx (0xF0..0xF7) is out of scope for v3.4.
9
+
10
+ All channel arguments are 0-indexed (channel 0 = MIDI channel 1 in DAWs).
11
+ All note / CC / value arguments are 0..=127.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+
16
+
17
+ # ── Message constructors ─────────────────────────────────────────────────────
18
+
19
+
20
+ def note_on(channel: int, note: int, velocity: int) -> bytes:
21
+ """NoteOn: 0x9c, note, velocity. velocity=0 is canonically NoteOff."""
22
+ _check_channel(channel)
23
+ _check_byte(note, "note")
24
+ _check_byte(velocity, "velocity")
25
+ return bytes([0x90 | (channel & 0x0F), note, velocity])
26
+
27
+
28
+ def note_off(channel: int, note: int, velocity: int = 0) -> bytes:
29
+ """NoteOff: 0x8c, note, velocity. velocity=0 is the conventional value."""
30
+ _check_channel(channel)
31
+ _check_byte(note, "note")
32
+ _check_byte(velocity, "velocity")
33
+ return bytes([0x80 | (channel & 0x0F), note, velocity])
34
+
35
+
36
+ def cc(channel: int, controller: int, value: int) -> bytes:
37
+ """Control Change: 0xBc, controller, value."""
38
+ _check_channel(channel)
39
+ _check_byte(controller, "controller")
40
+ _check_byte(value, "value")
41
+ return bytes([0xB0 | (channel & 0x0F), controller, value])
42
+
43
+
44
+ def program_change(channel: int, program: int) -> bytes:
45
+ """Program Change: 0xCc, program. 2-byte message (no second data byte)."""
46
+ _check_channel(channel)
47
+ _check_byte(program, "program")
48
+ return bytes([0xC0 | (channel & 0x0F), program])
49
+
50
+
51
+ def pitch_bend(channel: int, value: int) -> bytes:
52
+ """Pitch Bend: 0xEc, lsb, msb. `value` is a 14-bit unsigned integer
53
+ (0..=16383). 8192 is centre (no bend). Maps to (lsb=value & 0x7F,
54
+ msb=(value >> 7) & 0x7F).
55
+ """
56
+ _check_channel(channel)
57
+ if not 0 <= value <= 16383:
58
+ raise ValueError(f"pitch_bend value must be 0..=16383, got {value}")
59
+ lsb = value & 0x7F
60
+ msb = (value >> 7) & 0x7F
61
+ return bytes([0xE0 | (channel & 0x0F), lsb, msb])
62
+
63
+
64
+ def clock_tick() -> bytes:
65
+ """MIDI Clock pulse (0xF8). 24 ticks per quarter note. System real-time."""
66
+ return bytes([0xF8])
67
+
68
+
69
+ def start() -> bytes:
70
+ """MIDI Start (0xFA). System real-time."""
71
+ return bytes([0xFA])
72
+
73
+
74
+ def stop() -> bytes:
75
+ """MIDI Stop (0xFC). System real-time."""
76
+ return bytes([0xFC])
77
+
78
+
79
+ def continue_() -> bytes:
80
+ """MIDI Continue (0xFB). System real-time. Trailing underscore avoids the
81
+ Python keyword."""
82
+ return bytes([0xFB])
83
+
84
+
85
+ # ── Decoder ──────────────────────────────────────────────────────────────────
86
+
87
+
88
+ @dataclass
89
+ class MidiMessage:
90
+ """Decoded MIDI 1.0 message. `kind` is one of:
91
+
92
+ "note_on" — channel, note, velocity
93
+ "note_off" — channel, note, velocity
94
+ "cc" — channel, controller, value
95
+ "program" — channel, program (note: data2 is None)
96
+ "pitch_bend" — channel, value (14-bit)
97
+ "clock" — no data
98
+ "start" — no data
99
+ "stop" — no data
100
+ "continue" — no data
101
+ "unknown" — raw bytes preserved in `raw`
102
+
103
+ Fields not relevant to the message kind are None.
104
+ """
105
+ kind: str
106
+ raw: bytes
107
+ channel: "int | None" = None
108
+ note: "int | None" = None
109
+ velocity: "int | None" = None
110
+ controller: "int | None" = None
111
+ value: "int | None" = None
112
+ program: "int | None" = None
113
+
114
+
115
+ def decode(data: bytes) -> MidiMessage:
116
+ """Parse one MIDI 1.0 byte stream into a structured MidiMessage. Returns a
117
+ `MidiMessage(kind="unknown", raw=data)` for anything not in the v3.4
118
+ supported set."""
119
+ if not data:
120
+ return MidiMessage(kind="unknown", raw=bytes(data))
121
+ status = data[0]
122
+ raw = bytes(data)
123
+
124
+ # System real-time (1 byte).
125
+ if status == 0xF8:
126
+ return MidiMessage(kind="clock", raw=raw)
127
+ if status == 0xFA:
128
+ return MidiMessage(kind="start", raw=raw)
129
+ if status == 0xFB:
130
+ return MidiMessage(kind="continue", raw=raw)
131
+ if status == 0xFC:
132
+ return MidiMessage(kind="stop", raw=raw)
133
+
134
+ # Channel voice.
135
+ high = status & 0xF0
136
+ channel = status & 0x0F
137
+ if high == 0x90: # NoteOn (NoteOn with velocity 0 is conventionally NoteOff)
138
+ if len(data) < 3:
139
+ return MidiMessage(kind="unknown", raw=raw)
140
+ note, vel = data[1], data[2]
141
+ if vel == 0:
142
+ return MidiMessage(
143
+ kind="note_off", raw=raw, channel=channel, note=note, velocity=0
144
+ )
145
+ return MidiMessage(
146
+ kind="note_on", raw=raw, channel=channel, note=note, velocity=vel
147
+ )
148
+ if high == 0x80: # NoteOff
149
+ if len(data) < 3:
150
+ return MidiMessage(kind="unknown", raw=raw)
151
+ return MidiMessage(
152
+ kind="note_off",
153
+ raw=raw,
154
+ channel=channel,
155
+ note=data[1],
156
+ velocity=data[2],
157
+ )
158
+ if high == 0xB0: # CC
159
+ if len(data) < 3:
160
+ return MidiMessage(kind="unknown", raw=raw)
161
+ return MidiMessage(
162
+ kind="cc",
163
+ raw=raw,
164
+ channel=channel,
165
+ controller=data[1],
166
+ value=data[2],
167
+ )
168
+ if high == 0xC0: # Program Change (2 bytes)
169
+ if len(data) < 2:
170
+ return MidiMessage(kind="unknown", raw=raw)
171
+ return MidiMessage(
172
+ kind="program", raw=raw, channel=channel, program=data[1]
173
+ )
174
+ if high == 0xE0: # Pitch bend
175
+ if len(data) < 3:
176
+ return MidiMessage(kind="unknown", raw=raw)
177
+ lsb, msb = data[1], data[2]
178
+ value14 = (msb << 7) | lsb
179
+ return MidiMessage(
180
+ kind="pitch_bend", raw=raw, channel=channel, value=value14
181
+ )
182
+
183
+ return MidiMessage(kind="unknown", raw=raw)
184
+
185
+
186
+ def describe(data: bytes) -> str:
187
+ """One-line human-readable form of a MIDI message — useful for logs and
188
+ in-pane debug output."""
189
+ m = decode(data)
190
+ hex_str = " ".join(f"{b:02X}" for b in data)
191
+ if m.kind == "note_on":
192
+ return f"NoteOn ch={m.channel} note={m.note} vel={m.velocity} [{hex_str}]"
193
+ if m.kind == "note_off":
194
+ return f"NoteOff ch={m.channel} note={m.note} vel={m.velocity} [{hex_str}]"
195
+ if m.kind == "cc":
196
+ return f"CC ch={m.channel} num={m.controller} val={m.value} [{hex_str}]"
197
+ if m.kind == "program":
198
+ return f"Program ch={m.channel} prog={m.program} [{hex_str}]"
199
+ if m.kind == "pitch_bend":
200
+ return f"PitchBd ch={m.channel} val={m.value} [{hex_str}]"
201
+ if m.kind == "clock":
202
+ return f"Clock [{hex_str}]"
203
+ if m.kind == "start":
204
+ return f"Start [{hex_str}]"
205
+ if m.kind == "stop":
206
+ return f"Stop [{hex_str}]"
207
+ if m.kind == "continue":
208
+ return f"Continue [{hex_str}]"
209
+ return f"Unknown [{hex_str}]"
210
+
211
+
212
+ # ── Validation helpers ───────────────────────────────────────────────────────
213
+
214
+
215
+ def _check_channel(channel: int) -> None:
216
+ if not 0 <= channel <= 15:
217
+ raise ValueError(f"channel must be 0..=15, got {channel}")
218
+
219
+
220
+ def _check_byte(value: int, name: str) -> None:
221
+ if not 0 <= value <= 127:
222
+ raise ValueError(f"{name} must be 0..=127, got {value}")
plexi_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+
File without changes
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ __DISPLAY_NAME__ — generated by `plexi app init`.
4
+
5
+ Delete what you don't need.
6
+ """
7
+ from plexi_sdk import App, RenderContext
8
+ from plexi_sdk.ui import (
9
+ AppBar,
10
+ ButtonRow,
11
+ Card,
12
+ Column,
13
+ FooterKeys,
14
+ InfoTable,
15
+ KeyRow,
16
+ Label,
17
+ Section,
18
+ Spacer,
19
+ )
20
+
21
+
22
+ class __CLASS_NAME__(App):
23
+ async def on_init(self, ctx: RenderContext) -> None:
24
+ state = ctx.load_state()
25
+ self.click_count = state.get("clicks", 0)
26
+ self._btn = ButtonRow("action", "Click me")
27
+ ctx.status_summary("Ready")
28
+ self.emit.info("__DISPLAY_NAME__ initialized")
29
+
30
+ def on_render(self, ctx: RenderContext) -> None:
31
+ if self._btn.clicked:
32
+ self.click_count += 1
33
+ ctx.save_state({"clicks": self.click_count})
34
+ ctx.status_summary(f"Clicked {self.click_count} time(s)")
35
+
36
+ ctx.render(Column([
37
+ AppBar(title="__DISPLAY_NAME__", subtitle="Edit main.py to build your app"),
38
+ Section("APP INFO"),
39
+ InfoTable([
40
+ ("app_id", self.app_id),
41
+ ("workspace", ctx.workspace_root or "(none)"),
42
+ ("dimensions", f"{ctx.w:.0f} × {ctx.h:.0f}"),
43
+ ("capabilities", ", ".join(ctx.capabilities) or "none"),
44
+ ]),
45
+ Section("ACTIONS"),
46
+ Card([
47
+ self._btn,
48
+ Label(f"Clicks: {self.click_count}", tone="caption"),
49
+ Label(f"state stored in app_states/{self.app_id}.json — survives hot reload", tone="muted"),
50
+ ]),
51
+ Section("KEYBOARD SHORTCUTS"),
52
+ Card([
53
+ KeyRow("esc", "close"),
54
+ KeyRow("i", "increment counter"),
55
+ ]),
56
+ Spacer(grow=True),
57
+ FooterKeys([
58
+ ("esc", "close"),
59
+ ("i", "increment"),
60
+ ]),
61
+ ]))
62
+
63
+ def on_key(self, ctx: RenderContext, key: str, _mods: dict) -> None:
64
+ if key == "Escape":
65
+ self.emit.close_self()
66
+ elif key == "i":
67
+ self.click_count += 1
68
+ ctx.save_state({"clicks": self.click_count})
69
+ ctx.status_summary(f"Clicked {self.click_count} time(s)")
70
+
71
+
72
+ __CLASS_NAME__().run()