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.
Files changed (61) hide show
  1. py_sparcli-0.3.0.dist-info/METADATA +257 -0
  2. py_sparcli-0.3.0.dist-info/RECORD +61 -0
  3. py_sparcli-0.3.0.dist-info/WHEEL +4 -0
  4. py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  5. sparcli/__init__.py +184 -0
  6. sparcli/core/__init__.py +92 -0
  7. sparcli/core/border.py +160 -0
  8. sparcli/core/color.py +306 -0
  9. sparcli/core/cursor.py +59 -0
  10. sparcli/core/geometry.py +141 -0
  11. sparcli/core/markup.py +215 -0
  12. sparcli/core/render.py +245 -0
  13. sparcli/core/style.py +128 -0
  14. sparcli/core/terminal.py +127 -0
  15. sparcli/core/text.py +143 -0
  16. sparcli/core/theme.py +117 -0
  17. sparcli/core/width.py +244 -0
  18. sparcli/errors.py +38 -0
  19. sparcli/input/__init__.py +42 -0
  20. sparcli/input/calc.py +214 -0
  21. sparcli/input/confirm.py +188 -0
  22. sparcli/input/datepicker.py +298 -0
  23. sparcli/input/editor.py +189 -0
  24. sparcli/input/event.py +551 -0
  25. sparcli/input/field.py +90 -0
  26. sparcli/input/fuzzy.py +401 -0
  27. sparcli/input/guard.py +103 -0
  28. sparcli/input/history.py +160 -0
  29. sparcli/input/line_edit.py +229 -0
  30. sparcli/input/number.py +232 -0
  31. sparcli/input/outcome.py +107 -0
  32. sparcli/input/password.py +233 -0
  33. sparcli/input/prompt.py +134 -0
  34. sparcli/input/select.py +336 -0
  35. sparcli/input/shortcut.py +123 -0
  36. sparcli/input/text.py +564 -0
  37. sparcli/input/textarea.py +242 -0
  38. sparcli/input/validate.py +67 -0
  39. sparcli/output/__init__.py +56 -0
  40. sparcli/output/alert.py +107 -0
  41. sparcli/output/badge.py +82 -0
  42. sparcli/output/box.py +196 -0
  43. sparcli/output/columns.py +157 -0
  44. sparcli/output/compose.py +111 -0
  45. sparcli/output/diff.py +229 -0
  46. sparcli/output/kv.py +175 -0
  47. sparcli/output/layout.py +77 -0
  48. sparcli/output/list.py +274 -0
  49. sparcli/output/live.py +129 -0
  50. sparcli/output/multiprogress.py +73 -0
  51. sparcli/output/pager.py +83 -0
  52. sparcli/output/panel.py +125 -0
  53. sparcli/output/progress.py +233 -0
  54. sparcli/output/rule.py +134 -0
  55. sparcli/output/spinner.py +138 -0
  56. sparcli/output/table/__init__.py +17 -0
  57. sparcli/output/table/plan.py +198 -0
  58. sparcli/output/table/render.py +302 -0
  59. sparcli/output/table/table.py +351 -0
  60. sparcli/output/tree.py +157 -0
  61. 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))