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/__init__.py +477 -0
- plexi_sdk/_app.py +1077 -0
- plexi_sdk/_constants.py +52 -0
- plexi_sdk/_emitter.py +1466 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +976 -0
- plexi_sdk/_types.py +139 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/app_init.py +72 -0
- plexi_sdk/testing.py +451 -0
- plexi_sdk/ui.py +1535 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +60 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +159 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +218 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.4.0.dist-info/METADATA +127 -0
- plexi_sdk-0.4.0.dist-info/RECORD +25 -0
- plexi_sdk-0.4.0.dist-info/WHEEL +4 -0
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()
|