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
@@ -0,0 +1,127 @@
1
+ """
2
+ sparcli.core.terminal
3
+ =====================
4
+
5
+ Detects terminal capabilities and size.
6
+
7
+ The functions here answer three questions the renderer and prompts need: how
8
+ wide is the terminal, is standard output (or input) an interactive TTY, and how
9
+ much color can be emitted. Behaviour honours the widely supported environment
10
+ variables ``NO_COLOR``, ``CLICOLOR_FORCE`` and ``COLORTERM``, plus the test-only
11
+ override ``SPARCLI_NO_TTY``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import enum
17
+ import os
18
+ import shutil
19
+ import sys
20
+
21
+ DEFAULT_WIDTH = 80
22
+ DEFAULT_HEIGHT = 24
23
+
24
+ _NO_COLOR = "NO_COLOR"
25
+ _CLICOLOR_FORCE = "CLICOLOR_FORCE"
26
+ _COLORTERM = "COLORTERM"
27
+ _NO_TTY_OVERRIDE = "SPARCLI_NO_TTY"
28
+
29
+
30
+ class ColorSupport(enum.Enum):
31
+ """How much color the current output stream can display."""
32
+
33
+ NONE = enum.auto()
34
+ ANSI16 = enum.auto()
35
+ TRUECOLOR = enum.auto()
36
+
37
+
38
+ def terminal_size() -> tuple[int, int]:
39
+ """
40
+ Returns the terminal size as a ``(width, height)`` column/row pair.
41
+
42
+ Returns
43
+ -------
44
+ tuple[int, int]
45
+ The terminal width and height, falling back to ``(80, 24)`` when the
46
+ size cannot be determined.
47
+ """
48
+ try:
49
+ size = shutil.get_terminal_size(
50
+ fallback=(DEFAULT_WIDTH, DEFAULT_HEIGHT)
51
+ )
52
+ except (ValueError, OSError):
53
+ return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
54
+ return (size.columns, size.lines)
55
+
56
+
57
+ def term_width() -> int:
58
+ """Returns the terminal width in columns."""
59
+ return terminal_size()[0]
60
+
61
+
62
+ def term_height() -> int:
63
+ """Returns the terminal height in rows."""
64
+ return terminal_size()[1]
65
+
66
+
67
+ def is_output_tty() -> bool:
68
+ """Returns whether standard output is an interactive terminal."""
69
+ return not _no_tty_override() and _isatty(sys.stdout)
70
+
71
+
72
+ def is_input_tty() -> bool:
73
+ """
74
+ Returns whether both standard input and output are interactive terminals.
75
+
76
+ Prompts require input and output to be a TTY; either being redirected means
77
+ the prompt cannot run interactively.
78
+ """
79
+ return not _no_tty_override() and _isatty(sys.stdin) and _isatty(sys.stdout)
80
+
81
+
82
+ def color_support() -> ColorSupport:
83
+ """
84
+ Returns the color support level for the current output stream.
85
+
86
+ Returns
87
+ -------
88
+ ColorSupport
89
+ ``NONE`` when color is disabled (``NO_COLOR`` or a non-TTY without
90
+ ``CLICOLOR_FORCE``), ``TRUECOLOR`` when ``COLORTERM`` advertises 24-bit
91
+ color, otherwise ``ANSI16``.
92
+ """
93
+ if _NO_COLOR in os.environ:
94
+ return ColorSupport.NONE
95
+ if not _env_enabled(_CLICOLOR_FORCE) and not is_output_tty():
96
+ return ColorSupport.NONE
97
+ if _colorterm_truecolor():
98
+ return ColorSupport.TRUECOLOR
99
+ return ColorSupport.ANSI16
100
+
101
+
102
+ def _isatty(stream: object) -> bool:
103
+ """Returns whether a stream reports itself as a TTY, defensively."""
104
+ isatty = getattr(stream, "isatty", None)
105
+ if not callable(isatty):
106
+ return False
107
+ try:
108
+ return bool(isatty())
109
+ except (ValueError, OSError):
110
+ return False
111
+
112
+
113
+ def _no_tty_override() -> bool:
114
+ """Returns whether the SPARCLI_NO_TTY test override forces a non-TTY."""
115
+ return _env_enabled(_NO_TTY_OVERRIDE)
116
+
117
+
118
+ def _env_enabled(key: str) -> bool:
119
+ """Returns whether an env var is set to a non-empty value other than '0'."""
120
+ value = os.environ.get(key)
121
+ return value is not None and value != "" and value != "0"
122
+
123
+
124
+ def _colorterm_truecolor() -> bool:
125
+ """Returns whether COLORTERM advertises truecolor / 24-bit support."""
126
+ value = os.environ.get(_COLORTERM, "").lower()
127
+ return "truecolor" in value or "24bit" in value
sparcli/core/text.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ sparcli.core.text
3
+ =================
4
+
5
+ Defines the rich-text primitives :class:`Span`, :class:`Line` and
6
+ :class:`Text`.
7
+
8
+ A :class:`Span` is a run of text with a single style and an optional hyperlink.
9
+ A :class:`Line` is a sequence of spans; a :class:`Text` is a sequence of lines.
10
+ Together they mirror ratatui's text vocabulary. The ``into_*`` helpers coerce
11
+ plain strings into these types, so widget APIs can accept either.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field, replace
17
+
18
+ from sparcli.core.style import Style
19
+ from sparcli.core.width import visible_width
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Span:
24
+ """
25
+ A styled run of text with an optional hyperlink target.
26
+
27
+ Attributes
28
+ ----------
29
+ content : str
30
+ The text of the span.
31
+ style : Style
32
+ The style applied to the text.
33
+ link : str | None
34
+ An OSC-8 hyperlink target, or ``None``.
35
+ """
36
+
37
+ content: str = ""
38
+ style: Style = field(default_factory=Style)
39
+ link: str | None = None
40
+
41
+ @classmethod
42
+ def raw(cls, content: str) -> Span:
43
+ """Returns an unstyled span with no link."""
44
+ return cls(content=content)
45
+
46
+ @classmethod
47
+ def styled(cls, content: str, style: Style) -> Span:
48
+ """Returns a span with the given style."""
49
+ return cls(content=content, style=style)
50
+
51
+ def with_link(self, url: str) -> Span:
52
+ """Returns a copy of the span carrying an OSC-8 hyperlink."""
53
+ return replace(self, link=url)
54
+
55
+ def width(self) -> int:
56
+ """Returns the display width of the span in columns."""
57
+ return visible_width(self.content)
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class Line:
62
+ """A single line of text made of one or more :class:`Span` objects."""
63
+
64
+ spans: list[Span] = field(default_factory=list)
65
+
66
+ @classmethod
67
+ def raw(cls, content: str) -> Line:
68
+ """Returns a line holding a single unstyled span."""
69
+ return cls([Span.raw(content)])
70
+
71
+ @classmethod
72
+ def styled(cls, content: str, style: Style) -> Line:
73
+ """Returns a line holding a single styled span."""
74
+ return cls([Span.styled(content, style)])
75
+
76
+ def width(self) -> int:
77
+ """Returns the total display width of the line in columns."""
78
+ return sum(span.width() for span in self.spans)
79
+
80
+ def plain(self) -> str:
81
+ """Returns the line's text without any styling."""
82
+ return "".join(span.content for span in self.spans)
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class Text:
87
+ """A block of styled text made of zero or more :class:`Line` objects."""
88
+
89
+ lines: list[Line] = field(default_factory=list)
90
+
91
+ @classmethod
92
+ def raw(cls, content: str) -> Text:
93
+ """Returns text with ``content`` split into lines on newlines."""
94
+ return cls([Line.raw(part) for part in content.split("\n")])
95
+
96
+ @classmethod
97
+ def styled(cls, content: str, style: Style) -> Text:
98
+ """Returns styled text with ``content`` split on newlines."""
99
+ return cls([Line.styled(part, style) for part in content.split("\n")])
100
+
101
+ def push_line(self, line: IntoLine) -> None:
102
+ """Appends a line to the text."""
103
+ self.lines.append(into_line(line))
104
+
105
+ def width(self) -> int:
106
+ """Returns the width of the widest line in columns."""
107
+ return max((line.width() for line in self.lines), default=0)
108
+
109
+ def height(self) -> int:
110
+ """Returns the number of lines."""
111
+ return len(self.lines)
112
+
113
+
114
+ IntoSpan = str | Span
115
+ IntoLine = str | Span | Line
116
+ IntoText = str | Span | Line | Text
117
+
118
+
119
+ def into_span(value: IntoSpan) -> Span:
120
+ """Coerces a string or span into a :class:`Span`."""
121
+ if isinstance(value, Span):
122
+ return value
123
+ return Span.raw(value)
124
+
125
+
126
+ def into_line(value: IntoLine) -> Line:
127
+ """Coerces a string, span or line into a :class:`Line`."""
128
+ if isinstance(value, Line):
129
+ return value
130
+ if isinstance(value, Span):
131
+ return Line([value])
132
+ return Line.raw(value)
133
+
134
+
135
+ def into_text(value: IntoText) -> Text:
136
+ """Coerces a string, span, line or text into a :class:`Text`."""
137
+ if isinstance(value, Text):
138
+ return value
139
+ if isinstance(value, Line):
140
+ return Text([value])
141
+ if isinstance(value, Span):
142
+ return Text([Line([value])])
143
+ return Text.raw(value)
sparcli/core/theme.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ sparcli.core.theme
3
+ =================
4
+
5
+ Defines the unified :class:`Theme` shared by output widgets and input prompts.
6
+
7
+ A single process-wide theme drives accent color, semantic styles (success,
8
+ error, ...), the default border and whether Unicode or ASCII glyphs are used.
9
+ Read the active theme with :func:`theme` and replace it with :func:`set_theme`;
10
+ per-call widget options still override individual theme values.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from dataclasses import dataclass
17
+
18
+ from sparcli.core.border import BorderType
19
+ from sparcli.core.color import Color
20
+ from sparcli.core.style import Style
21
+
22
+ DEFAULT_ACCENT = Color.rgb(137, 180, 250)
23
+
24
+ _TITLE = Style.from_color(DEFAULT_ACCENT).bold()
25
+ _SECONDARY = Style.new().dim()
26
+ _SELECTION = Style.from_color(DEFAULT_ACCENT).bold()
27
+ _CURSOR = Style.new().with_fg(Color.BLACK).with_bg(DEFAULT_ACCENT)
28
+
29
+ # Two-step glyphs: (unicode, ascii). ASCII is the fallback for plain terminals.
30
+ _BULLET = ("•", "*")
31
+ _CURSOR_MARKER = ("‣ ", "> ")
32
+ _MARKER = (" ", " ")
33
+ _CHECKBOX_ON = ("◉ ", "[x] ")
34
+ _CHECKBOX_OFF = ("◯ ", "[ ] ")
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Theme:
39
+ """
40
+ The look shared across output and input.
41
+
42
+ Attributes
43
+ ----------
44
+ accent : Color
45
+ The single highlight color for titles, selections and active items.
46
+ title, heading, secondary, success, error, warning, info, debug, hint : Style
47
+ Semantic styles applied throughout the library.
48
+ selection, cursor : Style
49
+ Styles for the highlighted item and the text cursor.
50
+ border : BorderType
51
+ The default border look.
52
+ unicode : bool
53
+ When ``False``, glyph accessors return their ASCII fallbacks.
54
+ """
55
+
56
+ accent: Color = DEFAULT_ACCENT
57
+ title: Style = _TITLE
58
+ heading: Style = _TITLE
59
+ secondary: Style = _SECONDARY
60
+ success: Style = Style.from_color(Color.GREEN)
61
+ error: Style = Style.from_color(Color.RED)
62
+ warning: Style = Style.from_color(Color.YELLOW)
63
+ info: Style = Style.from_color(DEFAULT_ACCENT)
64
+ debug: Style = Style.from_color(Color.MAGENTA)
65
+ hint: Style = _SECONDARY
66
+ selection: Style = _SELECTION
67
+ cursor: Style = _CURSOR
68
+ border: BorderType = BorderType.ROUNDED
69
+ unicode: bool = True
70
+
71
+ def bullet(self) -> str:
72
+ """Returns the list bullet glyph."""
73
+ return self._glyph(_BULLET)
74
+
75
+ def cursor_marker(self) -> str:
76
+ """Returns the marker shown next to the active list item."""
77
+ return self._glyph(_CURSOR_MARKER)
78
+
79
+ def marker(self) -> str:
80
+ """Returns the blank marker aligning non-active list items."""
81
+ return self._glyph(_MARKER)
82
+
83
+ def checkbox_on(self) -> str:
84
+ """Returns the checked checkbox glyph."""
85
+ return self._glyph(_CHECKBOX_ON)
86
+
87
+ def checkbox_off(self) -> str:
88
+ """Returns the unchecked checkbox glyph."""
89
+ return self._glyph(_CHECKBOX_OFF)
90
+
91
+ def _glyph(self, pair: tuple[str, str]) -> str:
92
+ """Returns the unicode or ascii glyph based on the theme setting."""
93
+ return pair[0] if self.unicode else pair[1]
94
+
95
+
96
+ _lock = threading.Lock()
97
+ _current = Theme()
98
+
99
+
100
+ def theme() -> Theme:
101
+ """Returns the active process-wide theme."""
102
+ with _lock:
103
+ return _current
104
+
105
+
106
+ def set_theme(new_theme: Theme) -> None:
107
+ """
108
+ Replaces the active process-wide theme.
109
+
110
+ Parameters
111
+ ----------
112
+ new_theme : Theme
113
+ The theme to install. Widget options still override individual values.
114
+ """
115
+ global _current
116
+ with _lock:
117
+ _current = new_theme
sparcli/core/width.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ sparcli.core.width
3
+ ==================
4
+
5
+ Computes display width and reflows text, aware of Unicode and ANSI escapes.
6
+
7
+ Wide glyphs (CJK, many emoji) count as two columns, zero-width combining marks
8
+ as zero, and ANSI escape sequences as nothing. These helpers are the basis for
9
+ every alignment, truncation and wrapping decision in the library. Width is
10
+ derived from the standard library's :mod:`unicodedata`, so no third-party
11
+ dependency is required.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import unicodedata
17
+
18
+ _ESC = "\x1b"
19
+ _BELL = "\x07"
20
+ _CSI_FINAL_MIN = 0x40
21
+ _CSI_FINAL_MAX = 0x7E
22
+ _ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
23
+ _WIDE_EAST_ASIAN = frozenset({"W", "F"})
24
+
25
+
26
+ def char_width(char: str) -> int:
27
+ """
28
+ Returns the display width of a single character in terminal columns.
29
+
30
+ Parameters
31
+ ----------
32
+ char : str
33
+ A single character.
34
+
35
+ Returns
36
+ -------
37
+ int
38
+ ``0`` for combining or control characters, ``2`` for wide East Asian
39
+ glyphs, otherwise ``1``. An empty string is ``0`` and a multi-character
40
+ string sums its characters, so the function never raises on bad input.
41
+ """
42
+ if len(char) != 1:
43
+ return sum(char_width(part) for part in char)
44
+ if char == "\x00" or unicodedata.combining(char):
45
+ return 0
46
+ category = unicodedata.category(char)
47
+ if category in _ZERO_WIDTH_CATEGORIES or category == "Cc":
48
+ return 0
49
+ if unicodedata.east_asian_width(char) in _WIDE_EAST_ASIAN:
50
+ return 2
51
+ return 1
52
+
53
+
54
+ def visible_width(text: str) -> int:
55
+ """
56
+ Returns the column width of ``text``, ignoring ANSI escape sequences.
57
+
58
+ Parameters
59
+ ----------
60
+ text : str
61
+ The text to measure. May contain ANSI escapes.
62
+
63
+ Returns
64
+ -------
65
+ int
66
+ The number of terminal columns the text occupies.
67
+ """
68
+ if _ESC not in text:
69
+ return sum(char_width(char) for char in text)
70
+ return sum(char_width(char) for char in strip_ansi(text))
71
+
72
+
73
+ def strip_ansi(text: str) -> str:
74
+ """
75
+ Removes CSI and OSC escape sequences from ``text``.
76
+
77
+ Parameters
78
+ ----------
79
+ text : str
80
+ The text to clean.
81
+
82
+ Returns
83
+ -------
84
+ str
85
+ ``text`` with ANSI control sequences removed.
86
+ """
87
+ if _ESC not in text:
88
+ return text
89
+ out: list[str] = []
90
+ index = 0
91
+ length = len(text)
92
+ while index < length:
93
+ char = text[index]
94
+ if char == _ESC:
95
+ index = _skip_escape(text, index)
96
+ continue
97
+ out.append(char)
98
+ index += 1
99
+ return "".join(out)
100
+
101
+
102
+ def truncate(text: str, max_cols: int, ellipsis: str = "…") -> str:
103
+ """
104
+ Truncates ``text`` to ``max_cols`` columns, appending an ellipsis.
105
+
106
+ Parameters
107
+ ----------
108
+ text : str
109
+ The text to truncate. Assumed to contain no ANSI escapes.
110
+ max_cols : int
111
+ The maximum number of columns the result may occupy.
112
+ ellipsis : str
113
+ The marker appended when content is dropped.
114
+
115
+ Returns
116
+ -------
117
+ str
118
+ The original text if it fits, otherwise a truncated copy ending in the
119
+ ellipsis.
120
+ """
121
+ if max_cols <= 0:
122
+ return ""
123
+ if visible_width(text) <= max_cols:
124
+ return text
125
+ budget = max_cols - visible_width(ellipsis)
126
+ if budget <= 0:
127
+ return ellipsis[:max_cols]
128
+ kept: list[str] = []
129
+ used = 0
130
+ for char in text:
131
+ width = char_width(char)
132
+ if used + width > budget:
133
+ break
134
+ kept.append(char)
135
+ used += width
136
+ return "".join(kept) + ellipsis
137
+
138
+
139
+ def wrap(text: str, width: int) -> list[str]:
140
+ """
141
+ Wraps ``text`` to ``width`` columns on word boundaries.
142
+
143
+ Parameters
144
+ ----------
145
+ text : str
146
+ The text to wrap. Newlines split paragraphs first.
147
+ width : int
148
+ The maximum column width per line. A width of ``0`` disables wrapping.
149
+
150
+ Returns
151
+ -------
152
+ list[str]
153
+ The wrapped lines. Empty input yields a single empty line.
154
+ """
155
+ if width <= 0:
156
+ return [text]
157
+ lines: list[str] = []
158
+ for paragraph in text.split("\n"):
159
+ lines.extend(_wrap_paragraph(paragraph, width))
160
+ return lines or [""]
161
+
162
+
163
+ def _wrap_paragraph(text: str, width: int) -> list[str]:
164
+ """Wraps a single newline-free paragraph to ``width`` columns."""
165
+ lines: list[str] = []
166
+ current = ""
167
+ current_width = 0
168
+ for word in text.split():
169
+ word_width = visible_width(word)
170
+ gap = 1 if current else 0
171
+ if current and current_width + gap + word_width > width:
172
+ lines.append(current)
173
+ current = ""
174
+ current_width = 0
175
+ gap = 0
176
+ if word_width > width:
177
+ if current:
178
+ lines.append(current)
179
+ current = ""
180
+ current_width = 0
181
+ lines.extend(_split_long_word(word, width))
182
+ if lines:
183
+ current = lines.pop()
184
+ current_width = visible_width(current)
185
+ continue
186
+ current = f"{current} {word}" if current else word
187
+ current_width += gap + word_width
188
+ lines.append(current)
189
+ return lines
190
+
191
+
192
+ def _split_long_word(word: str, width: int) -> list[str]:
193
+ """Hard-splits a word wider than ``width`` into column-sized chunks."""
194
+ chunks: list[str] = []
195
+ current = ""
196
+ current_width = 0
197
+ for char in word:
198
+ char_w = char_width(char)
199
+ if current_width + char_w > width and current:
200
+ chunks.append(current)
201
+ current = ""
202
+ current_width = 0
203
+ current += char
204
+ current_width += char_w
205
+ if current:
206
+ chunks.append(current)
207
+ return chunks
208
+
209
+
210
+ def _skip_escape(text: str, index: int) -> int:
211
+ """Returns the index just past the escape sequence starting at ``index``."""
212
+ length = len(text)
213
+ if index + 1 >= length:
214
+ return index + 1
215
+ marker = text[index + 1]
216
+ if marker == "[":
217
+ return _skip_csi(text, index + 2)
218
+ if marker == "]":
219
+ return _skip_osc(text, index + 2)
220
+ return index + 2
221
+
222
+
223
+ def _skip_csi(text: str, index: int) -> int:
224
+ """Returns the index past a CSI sequence body starting at ``index``."""
225
+ length = len(text)
226
+ while index < length:
227
+ code = ord(text[index])
228
+ if _CSI_FINAL_MIN <= code <= _CSI_FINAL_MAX:
229
+ return index + 1
230
+ index += 1
231
+ return length
232
+
233
+
234
+ def _skip_osc(text: str, index: int) -> int:
235
+ """Returns the index past an OSC sequence starting at ``index``."""
236
+ length = len(text)
237
+ while index < length:
238
+ char = text[index]
239
+ if char == _BELL:
240
+ return index + 1
241
+ if char == _ESC and index + 1 < length and text[index + 1] == "\\":
242
+ return index + 2
243
+ index += 1
244
+ return length
sparcli/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ sparcli.errors
3
+ ==============
4
+
5
+ Defines the exception hierarchy raised across the library.
6
+
7
+ All errors derive from :class:`SparcliError`, so callers can catch the whole
8
+ family with a single ``except SparcliError``. More specific subclasses carry
9
+ context about what went wrong: terminal I/O, a missing interactive terminal or
10
+ invalid configuration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ class SparcliError(Exception):
17
+ """Base class for every error raised by sparcli."""
18
+
19
+
20
+ class TerminalError(SparcliError):
21
+ """Raised when reading from or writing to the terminal fails."""
22
+
23
+
24
+ class NoTerminalError(SparcliError):
25
+ """Raised when an interactive prompt runs without a usable terminal."""
26
+
27
+ def __init__(
28
+ self, message: str = "no interactive terminal available"
29
+ ) -> None:
30
+ super().__init__(message)
31
+
32
+
33
+ class ConfigError(SparcliError):
34
+ """Raised when a widget or prompt is configured with invalid options."""
35
+
36
+ def __init__(self, detail: str) -> None:
37
+ super().__init__(f"invalid configuration: {detail}")
38
+ self.detail = detail