py-sparcli 0.3.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.
- py_sparcli-0.3.0.dist-info/METADATA +257 -0
- py_sparcli-0.3.0.dist-info/RECORD +61 -0
- py_sparcli-0.3.0.dist-info/WHEEL +4 -0
- py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- sparcli/__init__.py +184 -0
- sparcli/core/__init__.py +92 -0
- sparcli/core/border.py +160 -0
- sparcli/core/color.py +306 -0
- sparcli/core/cursor.py +59 -0
- sparcli/core/geometry.py +141 -0
- sparcli/core/markup.py +215 -0
- sparcli/core/render.py +245 -0
- sparcli/core/style.py +128 -0
- sparcli/core/terminal.py +127 -0
- sparcli/core/text.py +143 -0
- sparcli/core/theme.py +117 -0
- sparcli/core/width.py +244 -0
- sparcli/errors.py +38 -0
- sparcli/input/__init__.py +42 -0
- sparcli/input/calc.py +214 -0
- sparcli/input/confirm.py +188 -0
- sparcli/input/datepicker.py +298 -0
- sparcli/input/editor.py +189 -0
- sparcli/input/event.py +551 -0
- sparcli/input/field.py +90 -0
- sparcli/input/fuzzy.py +401 -0
- sparcli/input/guard.py +103 -0
- sparcli/input/history.py +160 -0
- sparcli/input/line_edit.py +229 -0
- sparcli/input/number.py +232 -0
- sparcli/input/outcome.py +107 -0
- sparcli/input/password.py +233 -0
- sparcli/input/prompt.py +134 -0
- sparcli/input/select.py +336 -0
- sparcli/input/shortcut.py +123 -0
- sparcli/input/text.py +564 -0
- sparcli/input/textarea.py +242 -0
- sparcli/input/validate.py +67 -0
- sparcli/output/__init__.py +56 -0
- sparcli/output/alert.py +107 -0
- sparcli/output/badge.py +82 -0
- sparcli/output/box.py +196 -0
- sparcli/output/columns.py +157 -0
- sparcli/output/compose.py +111 -0
- sparcli/output/diff.py +229 -0
- sparcli/output/kv.py +175 -0
- sparcli/output/layout.py +77 -0
- sparcli/output/list.py +274 -0
- sparcli/output/live.py +129 -0
- sparcli/output/multiprogress.py +73 -0
- sparcli/output/pager.py +83 -0
- sparcli/output/panel.py +125 -0
- sparcli/output/progress.py +233 -0
- sparcli/output/rule.py +134 -0
- sparcli/output/spinner.py +138 -0
- sparcli/output/table/__init__.py +17 -0
- sparcli/output/table/plan.py +198 -0
- sparcli/output/table/render.py +302 -0
- sparcli/output/table/table.py +351 -0
- sparcli/output/tree.py +157 -0
- sparcli/py.typed +0 -0
sparcli/input/event.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.event
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines the keyboard-event abstraction and its event sources.
|
|
6
|
+
|
|
7
|
+
Prompts read :class:`InputEvent` values from an :class:`EventSource`. A
|
|
8
|
+
:class:`KeyCode` is a backend-independent logical key; a :class:`KeyPress`
|
|
9
|
+
bundles it with modifier flags. :class:`TerminalSource` reads real keystrokes
|
|
10
|
+
from the terminal using the standard library (``termios``/``tty`` on POSIX,
|
|
11
|
+
``msvcrt`` on Windows) and parses escape sequences into events.
|
|
12
|
+
:class:`ScriptedSource` is the test fake that replays a queued list of keys and
|
|
13
|
+
auto-cancels on exhaustion, so prompts can be driven headlessly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import enum
|
|
19
|
+
import os
|
|
20
|
+
import select
|
|
21
|
+
import sys
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from collections import deque
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from typing import ClassVar
|
|
26
|
+
|
|
27
|
+
# Timeout (seconds) used to disambiguate a lone Esc from an escape sequence.
|
|
28
|
+
_ESC_TIMEOUT = 0.05
|
|
29
|
+
# Timeout (seconds) used while draining a bracketed-paste payload.
|
|
30
|
+
_PASTE_TIMEOUT = 0.2
|
|
31
|
+
|
|
32
|
+
# Byte offset that turns a Ctrl-letter control byte into its ASCII letter.
|
|
33
|
+
_CTRL_LETTER_BASE = 0x60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KeyKind(enum.Enum):
|
|
37
|
+
"""The logical category of a :class:`KeyCode`."""
|
|
38
|
+
|
|
39
|
+
CHAR = enum.auto()
|
|
40
|
+
ENTER = enum.auto()
|
|
41
|
+
ESC = enum.auto()
|
|
42
|
+
TAB = enum.auto()
|
|
43
|
+
BACK_TAB = enum.auto()
|
|
44
|
+
BACKSPACE = enum.auto()
|
|
45
|
+
DELETE = enum.auto()
|
|
46
|
+
UP = enum.auto()
|
|
47
|
+
DOWN = enum.auto()
|
|
48
|
+
LEFT = enum.auto()
|
|
49
|
+
RIGHT = enum.auto()
|
|
50
|
+
HOME = enum.auto()
|
|
51
|
+
END = enum.auto()
|
|
52
|
+
PAGE_UP = enum.auto()
|
|
53
|
+
PAGE_DOWN = enum.auto()
|
|
54
|
+
FUNCTION = enum.auto()
|
|
55
|
+
UNKNOWN = enum.auto()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class KeyCode:
|
|
60
|
+
"""
|
|
61
|
+
A logical key, independent of the terminal backend.
|
|
62
|
+
|
|
63
|
+
Parameterless keys are available as class constants (``KeyCode.ENTER``);
|
|
64
|
+
printable characters and function keys are built with :meth:`char` and
|
|
65
|
+
:meth:`function`.
|
|
66
|
+
|
|
67
|
+
Attributes
|
|
68
|
+
----------
|
|
69
|
+
kind : KeyKind
|
|
70
|
+
The key category.
|
|
71
|
+
ch : str | None
|
|
72
|
+
The character for :attr:`KeyKind.CHAR`, otherwise ``None``.
|
|
73
|
+
n : int | None
|
|
74
|
+
The number for :attr:`KeyKind.FUNCTION`, otherwise ``None``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
kind: KeyKind
|
|
78
|
+
ch: str | None = None
|
|
79
|
+
n: int | None = None
|
|
80
|
+
|
|
81
|
+
ENTER: ClassVar[KeyCode]
|
|
82
|
+
ESC: ClassVar[KeyCode]
|
|
83
|
+
TAB: ClassVar[KeyCode]
|
|
84
|
+
BACK_TAB: ClassVar[KeyCode]
|
|
85
|
+
BACKSPACE: ClassVar[KeyCode]
|
|
86
|
+
DELETE: ClassVar[KeyCode]
|
|
87
|
+
UP: ClassVar[KeyCode]
|
|
88
|
+
DOWN: ClassVar[KeyCode]
|
|
89
|
+
LEFT: ClassVar[KeyCode]
|
|
90
|
+
RIGHT: ClassVar[KeyCode]
|
|
91
|
+
HOME: ClassVar[KeyCode]
|
|
92
|
+
END: ClassVar[KeyCode]
|
|
93
|
+
PAGE_UP: ClassVar[KeyCode]
|
|
94
|
+
PAGE_DOWN: ClassVar[KeyCode]
|
|
95
|
+
UNKNOWN: ClassVar[KeyCode]
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def char(cls, ch: str) -> KeyCode:
|
|
99
|
+
"""Returns a printable-character key."""
|
|
100
|
+
return cls(KeyKind.CHAR, ch=ch)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def function(cls, n: int) -> KeyCode:
|
|
104
|
+
"""Returns a function key ``F<n>``."""
|
|
105
|
+
return cls(KeyKind.FUNCTION, n=n)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SIMPLE_CODES: dict[str, KeyKind] = {
|
|
109
|
+
"ENTER": KeyKind.ENTER,
|
|
110
|
+
"ESC": KeyKind.ESC,
|
|
111
|
+
"TAB": KeyKind.TAB,
|
|
112
|
+
"BACK_TAB": KeyKind.BACK_TAB,
|
|
113
|
+
"BACKSPACE": KeyKind.BACKSPACE,
|
|
114
|
+
"DELETE": KeyKind.DELETE,
|
|
115
|
+
"UP": KeyKind.UP,
|
|
116
|
+
"DOWN": KeyKind.DOWN,
|
|
117
|
+
"LEFT": KeyKind.LEFT,
|
|
118
|
+
"RIGHT": KeyKind.RIGHT,
|
|
119
|
+
"HOME": KeyKind.HOME,
|
|
120
|
+
"END": KeyKind.END,
|
|
121
|
+
"PAGE_UP": KeyKind.PAGE_UP,
|
|
122
|
+
"PAGE_DOWN": KeyKind.PAGE_DOWN,
|
|
123
|
+
"UNKNOWN": KeyKind.UNKNOWN,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _install_simple_codes() -> None:
|
|
128
|
+
"""Attaches a class constant for every parameterless key code."""
|
|
129
|
+
for name, kind in _SIMPLE_CODES.items():
|
|
130
|
+
setattr(KeyCode, name, KeyCode(kind))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_install_simple_codes()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class KeyPress:
|
|
138
|
+
"""
|
|
139
|
+
A key press with its modifier flags.
|
|
140
|
+
|
|
141
|
+
Attributes
|
|
142
|
+
----------
|
|
143
|
+
code : KeyCode
|
|
144
|
+
The logical key.
|
|
145
|
+
ctrl, alt, shift : bool
|
|
146
|
+
The active modifier flags.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
code: KeyCode
|
|
150
|
+
ctrl: bool = False
|
|
151
|
+
alt: bool = False
|
|
152
|
+
shift: bool = False
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def new(cls, code: KeyCode) -> KeyPress:
|
|
156
|
+
"""Returns a key press with no modifiers."""
|
|
157
|
+
return cls(code)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def ctrl_key(cls, letter: str) -> KeyPress:
|
|
161
|
+
"""Returns a Ctrl + letter key press."""
|
|
162
|
+
return cls(KeyCode.char(letter), ctrl=True)
|
|
163
|
+
|
|
164
|
+
def is_ctrl(self, letter: str) -> bool:
|
|
165
|
+
"""Returns whether this is Ctrl + the given letter."""
|
|
166
|
+
return self.ctrl and self.code == KeyCode.char(letter)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class EventKind(enum.Enum):
|
|
170
|
+
"""The category of an :class:`InputEvent`."""
|
|
171
|
+
|
|
172
|
+
KEY = enum.auto()
|
|
173
|
+
PASTE = enum.auto()
|
|
174
|
+
RESIZE = enum.auto()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class InputEvent:
|
|
179
|
+
"""
|
|
180
|
+
An input event delivered to a prompt.
|
|
181
|
+
|
|
182
|
+
Attributes
|
|
183
|
+
----------
|
|
184
|
+
kind : EventKind
|
|
185
|
+
Which flavour of event this is.
|
|
186
|
+
key : KeyPress | None
|
|
187
|
+
The key press for :attr:`EventKind.KEY`, otherwise ``None``.
|
|
188
|
+
text : str | None
|
|
189
|
+
The pasted payload for :attr:`EventKind.PASTE`, otherwise ``None``.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
kind: EventKind
|
|
193
|
+
key: KeyPress | None = None
|
|
194
|
+
text: str | None = None
|
|
195
|
+
|
|
196
|
+
RESIZE: ClassVar[InputEvent]
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def from_key(cls, key: KeyPress) -> InputEvent:
|
|
200
|
+
"""Returns a key event."""
|
|
201
|
+
return cls(EventKind.KEY, key=key)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def paste(cls, text: str) -> InputEvent:
|
|
205
|
+
"""Returns a bracketed-paste event."""
|
|
206
|
+
return cls(EventKind.PASTE, text=text)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
InputEvent.RESIZE = InputEvent(EventKind.RESIZE)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class EventSource(ABC):
|
|
213
|
+
"""A source of input events."""
|
|
214
|
+
|
|
215
|
+
@abstractmethod
|
|
216
|
+
def next_event(self) -> InputEvent:
|
|
217
|
+
"""
|
|
218
|
+
Blocks until the next event is available and returns it.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
InputEvent
|
|
223
|
+
The next event from the source.
|
|
224
|
+
"""
|
|
225
|
+
raise NotImplementedError
|
|
226
|
+
|
|
227
|
+
def is_interactive(self) -> bool:
|
|
228
|
+
"""
|
|
229
|
+
Returns whether this source drives a real, interactive terminal.
|
|
230
|
+
|
|
231
|
+
Non-interactive sources (scripted tests) must not draw to the terminal,
|
|
232
|
+
so the prompt loop skips rendering for them.
|
|
233
|
+
"""
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _esc_event() -> InputEvent:
|
|
238
|
+
"""Returns an Esc key event, used as a safe fallback."""
|
|
239
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.ESC))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _decode_modifier(text: str) -> tuple[bool, bool, bool]:
|
|
243
|
+
"""Decodes a CSI modifier parameter into ``(ctrl, alt, shift)``."""
|
|
244
|
+
if not text:
|
|
245
|
+
return (False, False, False)
|
|
246
|
+
try:
|
|
247
|
+
mask = int(text) - 1
|
|
248
|
+
except ValueError:
|
|
249
|
+
return (False, False, False)
|
|
250
|
+
return (bool(mask & 4), bool(mask & 2), bool(mask & 1))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _poll_ready(fd: int, timeout: float) -> bool:
|
|
254
|
+
"""Returns whether ``fd`` has data ready within ``timeout`` seconds."""
|
|
255
|
+
try:
|
|
256
|
+
ready, _, _ = select.select([fd], [], [], timeout)
|
|
257
|
+
except (OSError, ValueError):
|
|
258
|
+
return False
|
|
259
|
+
return bool(ready)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ScriptedSource(EventSource):
|
|
263
|
+
"""
|
|
264
|
+
A scripted event source for tests: replays queued events in order.
|
|
265
|
+
|
|
266
|
+
On exhaustion it yields Esc, cancelling the prompt and preventing infinite
|
|
267
|
+
loops in headless tests.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
__slots__ = ("_events",)
|
|
271
|
+
|
|
272
|
+
def __init__(self, script: list[KeyCode | InputEvent]) -> None:
|
|
273
|
+
self._events: deque[InputEvent] = deque(
|
|
274
|
+
item
|
|
275
|
+
if isinstance(item, InputEvent)
|
|
276
|
+
else InputEvent.from_key(KeyPress.new(item))
|
|
277
|
+
for item in script
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
def keys(cls, codes: list[KeyCode]) -> ScriptedSource:
|
|
282
|
+
"""Builds a source from a sequence of key codes."""
|
|
283
|
+
return cls(list(codes))
|
|
284
|
+
|
|
285
|
+
def next_event(self) -> InputEvent:
|
|
286
|
+
"""Returns the next queued event, or Esc once exhausted."""
|
|
287
|
+
if not self._events:
|
|
288
|
+
return _esc_event()
|
|
289
|
+
return self._events.popleft()
|
|
290
|
+
|
|
291
|
+
def is_interactive(self) -> bool:
|
|
292
|
+
"""Returns ``False``: a scripted source never draws to a terminal."""
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# CSI final letters shared by cursor and editing keys.
|
|
297
|
+
_CSI_LETTER: dict[str, KeyCode] = {
|
|
298
|
+
"A": KeyCode.UP,
|
|
299
|
+
"B": KeyCode.DOWN,
|
|
300
|
+
"C": KeyCode.RIGHT,
|
|
301
|
+
"D": KeyCode.LEFT,
|
|
302
|
+
"H": KeyCode.HOME,
|
|
303
|
+
"F": KeyCode.END,
|
|
304
|
+
"Z": KeyCode.BACK_TAB,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# CSI ``<n>~`` numeric keys (navigation and function keys).
|
|
308
|
+
_CSI_TILDE: dict[int, KeyCode] = {
|
|
309
|
+
1: KeyCode.HOME,
|
|
310
|
+
2: KeyCode.UNKNOWN,
|
|
311
|
+
3: KeyCode.DELETE,
|
|
312
|
+
4: KeyCode.END,
|
|
313
|
+
5: KeyCode.PAGE_UP,
|
|
314
|
+
6: KeyCode.PAGE_DOWN,
|
|
315
|
+
7: KeyCode.HOME,
|
|
316
|
+
8: KeyCode.END,
|
|
317
|
+
11: KeyCode.function(1),
|
|
318
|
+
12: KeyCode.function(2),
|
|
319
|
+
13: KeyCode.function(3),
|
|
320
|
+
14: KeyCode.function(4),
|
|
321
|
+
15: KeyCode.function(5),
|
|
322
|
+
17: KeyCode.function(6),
|
|
323
|
+
18: KeyCode.function(7),
|
|
324
|
+
19: KeyCode.function(8),
|
|
325
|
+
20: KeyCode.function(9),
|
|
326
|
+
21: KeyCode.function(10),
|
|
327
|
+
23: KeyCode.function(11),
|
|
328
|
+
24: KeyCode.function(12),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# SS3 final letters (``ESC O x``), used by some terminals for F1-F4 and arrows.
|
|
332
|
+
_SS3_LETTER: dict[str, KeyCode] = {
|
|
333
|
+
"P": KeyCode.function(1),
|
|
334
|
+
"Q": KeyCode.function(2),
|
|
335
|
+
"R": KeyCode.function(3),
|
|
336
|
+
"S": KeyCode.function(4),
|
|
337
|
+
"A": KeyCode.UP,
|
|
338
|
+
"B": KeyCode.DOWN,
|
|
339
|
+
"C": KeyCode.RIGHT,
|
|
340
|
+
"D": KeyCode.LEFT,
|
|
341
|
+
"H": KeyCode.HOME,
|
|
342
|
+
"F": KeyCode.END,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Windows extended-key second characters returned after a ``\x00``/``\xe0``.
|
|
346
|
+
_WIN_SPECIAL: dict[str, KeyCode] = {
|
|
347
|
+
"H": KeyCode.UP,
|
|
348
|
+
"P": KeyCode.DOWN,
|
|
349
|
+
"K": KeyCode.LEFT,
|
|
350
|
+
"M": KeyCode.RIGHT,
|
|
351
|
+
"G": KeyCode.HOME,
|
|
352
|
+
"O": KeyCode.END,
|
|
353
|
+
"I": KeyCode.PAGE_UP,
|
|
354
|
+
"Q": KeyCode.PAGE_DOWN,
|
|
355
|
+
"S": KeyCode.DELETE,
|
|
356
|
+
";": KeyCode.function(1),
|
|
357
|
+
"<": KeyCode.function(2),
|
|
358
|
+
"=": KeyCode.function(3),
|
|
359
|
+
">": KeyCode.function(4),
|
|
360
|
+
"?": KeyCode.function(5),
|
|
361
|
+
"@": KeyCode.function(6),
|
|
362
|
+
"A": KeyCode.function(7),
|
|
363
|
+
"B": KeyCode.function(8),
|
|
364
|
+
"C": KeyCode.function(9),
|
|
365
|
+
"D": KeyCode.function(10),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class TerminalSource(EventSource):
|
|
370
|
+
"""
|
|
371
|
+
The real event source: reads keystrokes from the terminal.
|
|
372
|
+
|
|
373
|
+
Uses ``os.read`` plus ``select`` on POSIX and ``msvcrt`` on Windows. Raw
|
|
374
|
+
mode is expected to be enabled by a
|
|
375
|
+
:class:`~sparcli.input.guard.TerminalGuard` for the source's lifetime.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
__slots__ = ("_fd",)
|
|
379
|
+
|
|
380
|
+
def __init__(self) -> None:
|
|
381
|
+
try:
|
|
382
|
+
self._fd = sys.stdin.fileno()
|
|
383
|
+
except (OSError, ValueError):
|
|
384
|
+
self._fd = -1
|
|
385
|
+
|
|
386
|
+
def next_event(self) -> InputEvent:
|
|
387
|
+
"""Reads and returns the next terminal event."""
|
|
388
|
+
if sys.platform == "win32":
|
|
389
|
+
return self._next_windows()
|
|
390
|
+
return self._next_posix()
|
|
391
|
+
|
|
392
|
+
def _next_posix(self) -> InputEvent:
|
|
393
|
+
"""Reads the next event from a POSIX terminal."""
|
|
394
|
+
chunk = os.read(self._fd, 1)
|
|
395
|
+
if not chunk:
|
|
396
|
+
return _esc_event()
|
|
397
|
+
byte = chunk[0]
|
|
398
|
+
if byte == 0x1B:
|
|
399
|
+
return self._read_escape()
|
|
400
|
+
return self._decode_byte(byte)
|
|
401
|
+
|
|
402
|
+
def _read_escape(self) -> InputEvent:
|
|
403
|
+
"""Parses an escape sequence following an initial Esc byte."""
|
|
404
|
+
if not _poll_ready(self._fd, _ESC_TIMEOUT):
|
|
405
|
+
return _esc_event()
|
|
406
|
+
second = os.read(self._fd, 1)
|
|
407
|
+
if not second:
|
|
408
|
+
return _esc_event()
|
|
409
|
+
if second == b"[":
|
|
410
|
+
return self._read_csi()
|
|
411
|
+
if second == b"O":
|
|
412
|
+
return self._read_ss3()
|
|
413
|
+
inner = self._decode_byte(second[0])
|
|
414
|
+
if inner.key is not None:
|
|
415
|
+
return InputEvent.from_key(replace(inner.key, alt=True))
|
|
416
|
+
return inner
|
|
417
|
+
|
|
418
|
+
def _read_csi(self) -> InputEvent:
|
|
419
|
+
"""Reads a CSI sequence (``ESC [ ...``) up to its final byte."""
|
|
420
|
+
buf = bytearray()
|
|
421
|
+
while _poll_ready(self._fd, _ESC_TIMEOUT):
|
|
422
|
+
byte = os.read(self._fd, 1)
|
|
423
|
+
if not byte:
|
|
424
|
+
break
|
|
425
|
+
buf += byte
|
|
426
|
+
if 0x40 <= byte[0] <= 0x7E:
|
|
427
|
+
break
|
|
428
|
+
if not buf:
|
|
429
|
+
return _esc_event()
|
|
430
|
+
return self._decode_csi(bytes(buf))
|
|
431
|
+
|
|
432
|
+
def _decode_csi(self, seq: bytes) -> InputEvent:
|
|
433
|
+
"""Maps a CSI sequence body to an input event."""
|
|
434
|
+
text = seq.decode("ascii", "replace")
|
|
435
|
+
final = text[-1]
|
|
436
|
+
params = text[:-1]
|
|
437
|
+
if params == "200" and final == "~":
|
|
438
|
+
return self._read_paste()
|
|
439
|
+
numbers = params.split(";")
|
|
440
|
+
ctrl, alt, shift = _decode_modifier(
|
|
441
|
+
numbers[1] if len(numbers) > 1 else ""
|
|
442
|
+
)
|
|
443
|
+
if final == "~":
|
|
444
|
+
code = _CSI_TILDE.get(_to_int(numbers[0]), KeyCode.UNKNOWN)
|
|
445
|
+
else:
|
|
446
|
+
code = _CSI_LETTER.get(final, KeyCode.UNKNOWN)
|
|
447
|
+
press = KeyPress(code, ctrl=ctrl, alt=alt, shift=shift)
|
|
448
|
+
return InputEvent.from_key(press)
|
|
449
|
+
|
|
450
|
+
def _read_ss3(self) -> InputEvent:
|
|
451
|
+
"""Reads an SS3 sequence (``ESC O x``)."""
|
|
452
|
+
byte = os.read(self._fd, 1)
|
|
453
|
+
if not byte:
|
|
454
|
+
return _esc_event()
|
|
455
|
+
code = _SS3_LETTER.get(chr(byte[0]), KeyCode.UNKNOWN)
|
|
456
|
+
return InputEvent.from_key(KeyPress.new(code))
|
|
457
|
+
|
|
458
|
+
def _read_paste(self) -> InputEvent:
|
|
459
|
+
"""Drains a bracketed-paste payload up to its terminator."""
|
|
460
|
+
terminator = b"\x1b[201~"
|
|
461
|
+
data = bytearray()
|
|
462
|
+
while _poll_ready(self._fd, _PASTE_TIMEOUT):
|
|
463
|
+
byte = os.read(self._fd, 1)
|
|
464
|
+
if not byte:
|
|
465
|
+
break
|
|
466
|
+
data += byte
|
|
467
|
+
if data.endswith(terminator):
|
|
468
|
+
del data[-len(terminator) :]
|
|
469
|
+
break
|
|
470
|
+
return InputEvent.paste(data.decode("utf-8", "replace"))
|
|
471
|
+
|
|
472
|
+
def _decode_byte(self, byte: int) -> InputEvent:
|
|
473
|
+
"""Decodes a single leading byte into a key event."""
|
|
474
|
+
if byte in (0x0D, 0x0A):
|
|
475
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.ENTER))
|
|
476
|
+
if byte == 0x09:
|
|
477
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.TAB))
|
|
478
|
+
if byte in (0x7F, 0x08):
|
|
479
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.BACKSPACE))
|
|
480
|
+
if 0x01 <= byte <= 0x1A:
|
|
481
|
+
letter = chr(byte + _CTRL_LETTER_BASE)
|
|
482
|
+
return InputEvent.from_key(
|
|
483
|
+
KeyPress(KeyCode.char(letter), ctrl=True)
|
|
484
|
+
)
|
|
485
|
+
if byte < 0x20:
|
|
486
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.UNKNOWN))
|
|
487
|
+
text = self._read_utf8(byte)
|
|
488
|
+
if text is None:
|
|
489
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.UNKNOWN))
|
|
490
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.char(text)))
|
|
491
|
+
|
|
492
|
+
def _read_utf8(self, lead: int) -> str | None:
|
|
493
|
+
"""Completes a UTF-8 character given its leading byte."""
|
|
494
|
+
extra = _utf8_continuation_count(lead)
|
|
495
|
+
buf = bytearray([lead])
|
|
496
|
+
for _ in range(extra):
|
|
497
|
+
byte = os.read(self._fd, 1)
|
|
498
|
+
if not byte:
|
|
499
|
+
break
|
|
500
|
+
buf += byte
|
|
501
|
+
try:
|
|
502
|
+
return buf.decode("utf-8")
|
|
503
|
+
except UnicodeDecodeError:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
def _next_windows(self) -> InputEvent:
|
|
507
|
+
"""Reads the next event from a Windows console."""
|
|
508
|
+
import msvcrt
|
|
509
|
+
|
|
510
|
+
first = msvcrt.getwch()
|
|
511
|
+
if first in ("\x00", "\xe0"):
|
|
512
|
+
code = _WIN_SPECIAL.get(msvcrt.getwch(), KeyCode.UNKNOWN)
|
|
513
|
+
return InputEvent.from_key(KeyPress.new(code))
|
|
514
|
+
return self._decode_char_windows(first)
|
|
515
|
+
|
|
516
|
+
def _decode_char_windows(self, ch: str) -> InputEvent:
|
|
517
|
+
"""Decodes a single Windows character into a key event."""
|
|
518
|
+
byte = ord(ch)
|
|
519
|
+
if ch in ("\r", "\n"):
|
|
520
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.ENTER))
|
|
521
|
+
if ch == "\t":
|
|
522
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.TAB))
|
|
523
|
+
if ch in ("\x08", "\x7f"):
|
|
524
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.BACKSPACE))
|
|
525
|
+
if ch == "\x1b":
|
|
526
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.ESC))
|
|
527
|
+
if 0x01 <= byte <= 0x1A:
|
|
528
|
+
letter = chr(byte + _CTRL_LETTER_BASE)
|
|
529
|
+
return InputEvent.from_key(
|
|
530
|
+
KeyPress(KeyCode.char(letter), ctrl=True)
|
|
531
|
+
)
|
|
532
|
+
return InputEvent.from_key(KeyPress.new(KeyCode.char(ch)))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _to_int(text: str) -> int:
|
|
536
|
+
"""Parses an integer, defaulting to ``0`` on failure."""
|
|
537
|
+
try:
|
|
538
|
+
return int(text)
|
|
539
|
+
except ValueError:
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _utf8_continuation_count(lead: int) -> int:
|
|
544
|
+
"""Returns how many continuation bytes follow a UTF-8 leading byte."""
|
|
545
|
+
if lead >= 0xF0:
|
|
546
|
+
return 3
|
|
547
|
+
if lead >= 0xE0:
|
|
548
|
+
return 2
|
|
549
|
+
if lead >= 0xC0:
|
|
550
|
+
return 1
|
|
551
|
+
return 0
|
sparcli/input/field.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.field
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines the shared line-rendering helpers for text-entry prompts.
|
|
6
|
+
|
|
7
|
+
Every text prompt renders a labelled field with a block cursor, a final value
|
|
8
|
+
line without a cursor, a dim placeholder line, or an error line. These helpers
|
|
9
|
+
draw those four shapes consistently so the prompts share one look.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from sparcli.core.style import Style
|
|
15
|
+
from sparcli.core.text import Line, Span
|
|
16
|
+
from sparcli.core.theme import Theme
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def field_line(
|
|
20
|
+
prompt: str, display: str, cursor: int, style: Style, theme: Theme
|
|
21
|
+
) -> Line:
|
|
22
|
+
"""
|
|
23
|
+
Renders a labelled single-line field with a block cursor.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
prompt : str
|
|
28
|
+
The field label; omitted when empty.
|
|
29
|
+
display : str
|
|
30
|
+
The already-prepared text (e.g. masked for passwords).
|
|
31
|
+
cursor : int
|
|
32
|
+
The character index the block cursor sits on.
|
|
33
|
+
style : Style
|
|
34
|
+
The style for the entered text.
|
|
35
|
+
theme : Theme
|
|
36
|
+
The active theme, for the title and cursor styles.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
Line
|
|
41
|
+
The rendered field line.
|
|
42
|
+
"""
|
|
43
|
+
spans: list[Span] = []
|
|
44
|
+
if prompt:
|
|
45
|
+
spans.append(Span.styled(f"{prompt} ", theme.title))
|
|
46
|
+
_push_with_cursor(spans, display, cursor, style, theme.cursor)
|
|
47
|
+
return Line(spans)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def value_line(prompt: str, display: str, style: Style, theme: Theme) -> Line:
|
|
51
|
+
"""Renders a labelled value without any cursor (the final frame)."""
|
|
52
|
+
spans: list[Span] = []
|
|
53
|
+
if prompt:
|
|
54
|
+
spans.append(Span.styled(f"{prompt} ", theme.title))
|
|
55
|
+
if display:
|
|
56
|
+
spans.append(Span.styled(display, style))
|
|
57
|
+
return Line(spans)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def placeholder_line(prompt: str, placeholder: str, theme: Theme) -> Line:
|
|
61
|
+
"""Renders dim placeholder text with the cursor at the start."""
|
|
62
|
+
spans: list[Span] = []
|
|
63
|
+
if prompt:
|
|
64
|
+
spans.append(Span.styled(f"{prompt} ", theme.title))
|
|
65
|
+
spans.append(Span.styled(" ", theme.cursor))
|
|
66
|
+
spans.append(Span.styled(placeholder, theme.secondary))
|
|
67
|
+
return Line(spans)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def error_line(message: str, theme: Theme) -> Line:
|
|
71
|
+
"""Renders an error message line in the theme's error style."""
|
|
72
|
+
return Line.styled(f" {message}", theme.error)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _push_with_cursor(
|
|
76
|
+
spans: list[Span],
|
|
77
|
+
display: str,
|
|
78
|
+
cursor: int,
|
|
79
|
+
style: Style,
|
|
80
|
+
cursor_style: Style,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Splits ``display`` at the cursor and pushes spans with a block cursor."""
|
|
83
|
+
chars = list(display)
|
|
84
|
+
before = "".join(chars[:cursor])
|
|
85
|
+
if before:
|
|
86
|
+
spans.append(Span.styled(before, style))
|
|
87
|
+
at = chars[cursor] if cursor < len(chars) else " "
|
|
88
|
+
spans.append(Span.styled(at, cursor_style))
|
|
89
|
+
if cursor + 1 < len(chars):
|
|
90
|
+
spans.append(Span.styled("".join(chars[cursor + 1 :]), style))
|