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/output/kv.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ sparcli.output.kv
3
+ =================
4
+
5
+ Defines :class:`KeyValue`, an aligned list of key-value pairs.
6
+
7
+ Keys are padded to a common column width (the widest key, or a fixed width) and
8
+ joined to their values by a separator. Keys are bold by default and values use
9
+ the secondary style; long values can be wrapped across several rows, with the
10
+ key shown only on the first row. Both a keyword constructor and fluent builders
11
+ are provided.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from sparcli.core.geometry import Edges
19
+ from sparcli.core.render import Renderable, Rendered
20
+ from sparcli.core.style import Style
21
+ from sparcli.core.text import IntoText, Line, Span, Text, into_text
22
+ from sparcli.core.theme import theme
23
+ from sparcli.core.width import visible_width, wrap
24
+ from sparcli.output.compose import pad
25
+
26
+ _DEFAULT_SEPARATOR = " "
27
+ _MIN_VALUE_WIDTH = 1
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _Pair:
32
+ """One key-value pair with a plain key and rich value."""
33
+
34
+ key: str
35
+ value: Text = field(default_factory=Text)
36
+
37
+
38
+ class KeyValue(Renderable):
39
+ """A list of aligned key-value pairs."""
40
+
41
+ __slots__ = (
42
+ "_pairs",
43
+ "_separator",
44
+ "_key_width",
45
+ "_key_style",
46
+ "_value_style",
47
+ "_item_gap",
48
+ "_wrap_values",
49
+ "_margin",
50
+ )
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ separator: str = _DEFAULT_SEPARATOR,
56
+ key_width: int | None = None,
57
+ key_style: Style | None = None,
58
+ value_style: Style | None = None,
59
+ item_gap: int = 0,
60
+ wrap_values: bool = False,
61
+ margin: Edges | None = None,
62
+ ) -> None:
63
+ self._pairs: list[_Pair] = []
64
+ self._separator = separator
65
+ self._key_width = key_width
66
+ self._key_style = (
67
+ key_style if key_style is not None else Style.new().bold()
68
+ )
69
+ self._value_style = (
70
+ value_style if value_style is not None else theme().secondary
71
+ )
72
+ self._item_gap = item_gap
73
+ self._wrap_values = wrap_values
74
+ self._margin = margin if margin is not None else Edges()
75
+
76
+ def add(self, key: str, value: IntoText) -> KeyValue:
77
+ """Adds a key-value pair and returns the list."""
78
+ self._pairs.append(_Pair(key=key, value=into_text(value)))
79
+ return self
80
+
81
+ def separator(self, separator: str) -> KeyValue:
82
+ """Sets the separator between key and value and returns the list."""
83
+ self._separator = separator
84
+ return self
85
+
86
+ def key_width(self, width: int) -> KeyValue:
87
+ """Sets a fixed key column width and returns the list."""
88
+ self._key_width = width
89
+ return self
90
+
91
+ def key_style(self, style: Style) -> KeyValue:
92
+ """Sets the key style and returns the list."""
93
+ self._key_style = style
94
+ return self
95
+
96
+ def value_style(self, style: Style) -> KeyValue:
97
+ """Sets the value style and returns the list."""
98
+ self._value_style = style
99
+ return self
100
+
101
+ def item_gap(self, gap: int) -> KeyValue:
102
+ """Sets the number of blank lines between pairs and returns the list."""
103
+ self._item_gap = gap
104
+ return self
105
+
106
+ def wrap_values(self, wrap_values: bool) -> KeyValue:
107
+ """Enables or disables value wrapping and returns the list."""
108
+ self._wrap_values = wrap_values
109
+ return self
110
+
111
+ def margin(self, margin: Edges) -> KeyValue:
112
+ """Sets the outer margin and returns the list."""
113
+ self._margin = margin
114
+ return self
115
+
116
+ def render(self, max_width: int) -> Rendered:
117
+ """Renders the aligned pairs into at most ``max_width`` columns."""
118
+ key_width = self._resolved_key_width()
119
+ prefix_width = key_width + visible_width(self._separator)
120
+ value_width = max(_MIN_VALUE_WIDTH, max_width - prefix_width)
121
+ lines: list[Line] = []
122
+ for index, pair in enumerate(self._pairs):
123
+ if index > 0:
124
+ _push_gap(lines, self._item_gap)
125
+ self._push_pair(lines, pair, key_width, value_width)
126
+ return pad(Rendered(lines), self._margin)
127
+
128
+ def _resolved_key_width(self) -> int:
129
+ """Returns the effective key column width."""
130
+ if self._key_width is not None:
131
+ return self._key_width
132
+ return max((visible_width(pair.key) for pair in self._pairs), default=0)
133
+
134
+ def _push_pair(
135
+ self,
136
+ lines: list[Line],
137
+ pair: _Pair,
138
+ key_width: int,
139
+ value_width: int,
140
+ ) -> None:
141
+ """Renders one pair, wrapping the value into rows when enabled."""
142
+ for row, value_line in enumerate(self._value_lines(pair, value_width)):
143
+ key_cell = pair.key if row == 0 else ""
144
+ lines.append(self._compose_line(key_cell, key_width, value_line))
145
+
146
+ def _value_lines(self, pair: _Pair, value_width: int) -> list[Line]:
147
+ """Splits a pair's value into one or more display lines."""
148
+ if not self._wrap_values:
149
+ return list(pair.value.lines)
150
+ out: list[Line] = []
151
+ for line in pair.value.lines:
152
+ for chunk in wrap(line.plain(), value_width):
153
+ out.append(Line.styled(chunk, self._value_style))
154
+ return out
155
+
156
+ def _compose_line(self, key: str, key_width: int, value_line: Line) -> Line:
157
+ """Composes a padded key cell, separator and value into one line."""
158
+ key_pad = max(0, key_width - visible_width(key))
159
+ spans = [
160
+ Span.styled(key, self._key_style),
161
+ Span.raw(" " * key_pad),
162
+ Span.raw(self._separator),
163
+ ]
164
+ for span in value_line.spans:
165
+ style = self._value_style.patch(span.style)
166
+ spans.append(
167
+ Span(content=span.content, style=style, link=span.link)
168
+ )
169
+ return Line(spans)
170
+
171
+
172
+ def _push_gap(lines: list[Line], count: int) -> None:
173
+ """Pushes ``count`` blank lines onto ``lines``."""
174
+ for _ in range(max(0, count)):
175
+ lines.append(Line())
@@ -0,0 +1,77 @@
1
+ """
2
+ sparcli.output.layout
3
+ =====================
4
+
5
+ Internal line-building helpers shared by the output widgets.
6
+
7
+ These small functions create blank lines and runs of spaces, and pad a line to
8
+ a target width with a fill style. They keep the widget code focused on layout
9
+ decisions instead of span bookkeeping.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from sparcli.core.geometry import Align
15
+ from sparcli.core.style import Style
16
+ from sparcli.core.text import Line, Span
17
+
18
+
19
+ def space_span(count: int, style: Style | None = None) -> Span:
20
+ """Returns a span of ``count`` spaces with an optional fill style."""
21
+ width = max(0, count)
22
+ return Span(content=" " * width, style=style or Style.new())
23
+
24
+
25
+ def blank_line(width: int = 0, style: Style | None = None) -> Line:
26
+ """Returns an empty line, optionally filled with ``width`` spaces."""
27
+ if width <= 0:
28
+ return Line()
29
+ return Line([space_span(width, style)])
30
+
31
+
32
+ def pad_line(
33
+ line: Line,
34
+ width: int,
35
+ align: Align = Align.LEFT,
36
+ style: Style | None = None,
37
+ ) -> Line:
38
+ """
39
+ Pads a line to ``width`` columns using ``align`` and a fill style.
40
+
41
+ Parameters
42
+ ----------
43
+ line : Line
44
+ The line to pad. Lines wider than ``width`` are returned unchanged.
45
+ width : int
46
+ The target width in columns.
47
+ align : Align
48
+ How to distribute the padding.
49
+ style : Style | None
50
+ The style applied to the padding spaces.
51
+
52
+ Returns
53
+ -------
54
+ Line
55
+ A new line padded to ``width``.
56
+ """
57
+ deficit = width - line.width()
58
+ if deficit <= 0:
59
+ return Line(list(line.spans))
60
+ left, right = _split_padding(deficit, align)
61
+ spans: list[Span] = []
62
+ if left:
63
+ spans.append(space_span(left, style))
64
+ spans.extend(line.spans)
65
+ if right:
66
+ spans.append(space_span(right, style))
67
+ return Line(spans)
68
+
69
+
70
+ def _split_padding(deficit: int, align: Align) -> tuple[int, int]:
71
+ """Splits a padding deficit into left and right amounts for an alignment."""
72
+ if align is Align.LEFT:
73
+ return (0, deficit)
74
+ if align is Align.RIGHT:
75
+ return (deficit, 0)
76
+ left = deficit // 2
77
+ return (left, deficit - left)
sparcli/output/list.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ sparcli.output.list
3
+ ===================
4
+
5
+ Defines :class:`List` and the :class:`Marker` enum: bulleted and ordered lists
6
+ with nesting and hanging indents.
7
+
8
+ A list prefixes each item with a marker (a bullet or an ordered label such as
9
+ ``1.``, ``a.`` or ``iv.``). Continuation rows of a multi-line item align under
10
+ the content with a hanging indent, and nested sub-lists added via
11
+ :meth:`List.item_with` are indented beneath their parent item.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import enum
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+
20
+ from sparcli.core.geometry import Align, Edges
21
+ from sparcli.core.render import Renderable, Rendered
22
+ from sparcli.core.style import Style
23
+ from sparcli.core.text import IntoText, Line, Span, Text, into_text
24
+ from sparcli.core.theme import theme
25
+ from sparcli.core.width import visible_width
26
+ from sparcli.output.layout import blank_line, pad_line, space_span
27
+
28
+ # Alphabet size for bijective base-26 alphabetic markers.
29
+ _ALPHA_BASE = 26
30
+
31
+ # Roman-numeral values paired with their lowercase symbols, high to low.
32
+ _NUMERALS: tuple[tuple[int, str], ...] = (
33
+ (1000, "m"),
34
+ (900, "cm"),
35
+ (500, "d"),
36
+ (400, "cd"),
37
+ (100, "c"),
38
+ (90, "xc"),
39
+ (50, "l"),
40
+ (40, "xl"),
41
+ (10, "x"),
42
+ (9, "ix"),
43
+ (5, "v"),
44
+ (4, "iv"),
45
+ (1, "i"),
46
+ )
47
+
48
+
49
+ class Marker(enum.Enum):
50
+ """The marker style used to label list items."""
51
+
52
+ BULLET = enum.auto()
53
+ NUMBER = enum.auto()
54
+ ALPHA_LOWER = enum.auto()
55
+ ALPHA_UPPER = enum.auto()
56
+ ROMAN_LOWER = enum.auto()
57
+ ROMAN_UPPER = enum.auto()
58
+
59
+
60
+ # Ordered-marker label builders keyed by marker, given a zero-based index.
61
+ _ORDERED_CORE: dict[Marker, Callable[[int], str]] = {
62
+ Marker.NUMBER: lambda index: str(index + 1),
63
+ Marker.ALPHA_LOWER: lambda index: _to_alpha(index, upper=False),
64
+ Marker.ALPHA_UPPER: lambda index: _to_alpha(index, upper=True),
65
+ Marker.ROMAN_LOWER: lambda index: _to_roman(index + 1, upper=False),
66
+ Marker.ROMAN_UPPER: lambda index: _to_roman(index + 1, upper=True),
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class _ListItem:
72
+ """One list entry with an optional nested sub-list."""
73
+
74
+ content: Text
75
+ children: List | None = field(default=None)
76
+
77
+
78
+ class List(Renderable):
79
+ """A bulleted or ordered list with optional nesting."""
80
+
81
+ __slots__ = (
82
+ "_marker",
83
+ "_items",
84
+ "_marker_style",
85
+ "_bullet",
86
+ "_suffix",
87
+ "_indent",
88
+ "_item_gap",
89
+ "_margin",
90
+ )
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ marker: Marker = Marker.BULLET,
96
+ marker_style: Style | None = None,
97
+ bullet: str | None = None,
98
+ suffix: str = "",
99
+ indent: int = 0,
100
+ item_gap: int = 0,
101
+ margin: Edges | None = None,
102
+ ) -> None:
103
+ self._marker = marker
104
+ self._items: list[_ListItem] = []
105
+ self._marker_style = (
106
+ marker_style if marker_style is not None else theme().secondary
107
+ )
108
+ self._bullet = bullet
109
+ self._suffix = suffix
110
+ self._indent = indent
111
+ self._item_gap = item_gap
112
+ self._margin = margin if margin is not None else Edges()
113
+
114
+ @classmethod
115
+ def ordered(cls, marker: Marker) -> List:
116
+ """
117
+ Returns an empty ordered list using ``marker`` with a ``.`` suffix.
118
+
119
+ Parameters
120
+ ----------
121
+ marker : Marker
122
+ The ordered marker style. ``Marker.BULLET`` yields no suffix.
123
+
124
+ Returns
125
+ -------
126
+ List
127
+ The configured, empty list.
128
+ """
129
+ suffix = "" if marker is Marker.BULLET else "."
130
+ return cls(marker=marker, suffix=suffix)
131
+
132
+ def item(self, content: IntoText) -> List:
133
+ """Adds a leaf item and returns the list."""
134
+ self._items.append(_ListItem(content=into_text(content)))
135
+ return self
136
+
137
+ def item_with(self, content: IntoText, children: List) -> List:
138
+ """Adds an item carrying a nested sub-list and returns the list."""
139
+ self._items.append(
140
+ _ListItem(content=into_text(content), children=children)
141
+ )
142
+ return self
143
+
144
+ def bullet(self, glyph: str) -> List:
145
+ """Sets a custom bullet glyph (bullet marker only)."""
146
+ self._bullet = glyph
147
+ return self
148
+
149
+ def marker_style(self, style: Style) -> List:
150
+ """Sets the marker style and returns the list."""
151
+ self._marker_style = style
152
+ return self
153
+
154
+ def indent(self, indent: int) -> List:
155
+ """Sets the left indent in columns and returns the list."""
156
+ self._indent = indent
157
+ return self
158
+
159
+ def item_gap(self, gap: int) -> List:
160
+ """Sets the number of blank lines between items."""
161
+ self._item_gap = gap
162
+ return self
163
+
164
+ def margin(self, margin: Edges) -> List:
165
+ """Sets the outer margin and returns the list."""
166
+ self._margin = margin
167
+ return self
168
+
169
+ def render(self, max_width: int) -> Rendered:
170
+ """Renders the list; ``max_width`` is accepted but not constraining."""
171
+ del max_width
172
+ return self._apply_margin(Rendered(self._render_lines()))
173
+
174
+ def _apply_margin(self, block: Rendered) -> Rendered:
175
+ """Surrounds ``block`` with the outer margin, matching Rust ``pad``."""
176
+ edges = self._margin
177
+ content_width = block.width()
178
+ inner_width = content_width + edges.horizontal()
179
+ lines: list[Line] = []
180
+ for _ in range(edges.top):
181
+ lines.append(blank_line(inner_width))
182
+ for line in block.lines:
183
+ lines.append(_pad_content_line(line, edges, content_width))
184
+ for _ in range(edges.bottom):
185
+ lines.append(blank_line(inner_width))
186
+ return Rendered(lines)
187
+
188
+ def _marker_label(self, index: int) -> str:
189
+ """Returns the marker label for the item at ``index``."""
190
+ if self._marker is Marker.BULLET:
191
+ glyph = (
192
+ self._bullet if self._bullet is not None else theme().bullet()
193
+ )
194
+ return f"{glyph} "
195
+ core = _ORDERED_CORE[self._marker](index)
196
+ return f"{core}{self._suffix} "
197
+
198
+ def _render_lines(self) -> list[Line]:
199
+ """Renders the list into a flat list of lines, without the margin."""
200
+ lines: list[Line] = []
201
+ for index, item in enumerate(self._items):
202
+ if index > 0:
203
+ for _ in range(self._item_gap):
204
+ lines.append(Line())
205
+ self._render_item(index, item, lines)
206
+ return lines
207
+
208
+ def _render_item(
209
+ self, index: int, item: _ListItem, lines: list[Line]
210
+ ) -> None:
211
+ """Renders one item and its children into ``lines``."""
212
+ label = self._marker_label(index)
213
+ label_width = visible_width(label)
214
+ indent = " " * self._indent
215
+ hang = " " * (self._indent + label_width)
216
+ for row, content_line in enumerate(item.content.lines):
217
+ spans: list[Span] = []
218
+ if row == 0:
219
+ spans.append(Span.raw(indent))
220
+ spans.append(Span.styled(label, self._marker_style))
221
+ else:
222
+ spans.append(Span.raw(hang))
223
+ spans.extend(content_line.spans)
224
+ lines.append(Line(spans))
225
+ self._render_children(item, label_width, lines)
226
+
227
+ def _render_children(
228
+ self, item: _ListItem, label_width: int, lines: list[Line]
229
+ ) -> None:
230
+ """Renders a nested sub-list indented under its parent item."""
231
+ if item.children is None:
232
+ return
233
+ hang = self._indent + label_width
234
+ for child_line in item.children._render_lines():
235
+ spans: list[Span] = [Span.raw(" " * hang)]
236
+ spans.extend(child_line.spans)
237
+ lines.append(Line(spans))
238
+
239
+
240
+ def _pad_content_line(line: Line, edges: Edges, content_width: int) -> Line:
241
+ """Adds left/right space columns around one content line."""
242
+ spans: list[Span] = []
243
+ if edges.left > 0:
244
+ spans.append(space_span(edges.left))
245
+ if edges.right > 0:
246
+ padded = pad_line(line, content_width, Align.LEFT)
247
+ spans.extend(padded.spans)
248
+ spans.append(space_span(edges.right))
249
+ else:
250
+ spans.extend(line.spans)
251
+ return Line(spans)
252
+
253
+
254
+ def _to_alpha(index: int, *, upper: bool) -> str:
255
+ """Converts a zero-based index to bijective base-26 letters."""
256
+ value = index + 1
257
+ chars: list[str] = []
258
+ base = ord("A") if upper else ord("a")
259
+ while value > 0:
260
+ rem = (value - 1) % _ALPHA_BASE
261
+ chars.append(chr(base + rem))
262
+ value = (value - 1) // _ALPHA_BASE
263
+ return "".join(reversed(chars))
264
+
265
+
266
+ def _to_roman(value: int, *, upper: bool) -> str:
267
+ """Converts a positive integer to a roman numeral."""
268
+ out: list[str] = []
269
+ for amount, symbol in _NUMERALS:
270
+ while value >= amount:
271
+ out.append(symbol)
272
+ value -= amount
273
+ text = "".join(out)
274
+ return text.upper() if upper else text
sparcli/output/live.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ sparcli.output.live
3
+ ==================
4
+
5
+ Defines the in-place redraw engine :class:`InPlace` and the :class:`Live` view.
6
+
7
+ :class:`InPlace` rewinds the cursor and rewrites a frame in place; it powers
8
+ spinners, progress bars, multi-progress groups and every interactive prompt.
9
+ Off a terminal it emits no control codes and prints only the final frame once,
10
+ so piped and captured output stays clean. :class:`Live` is the public wrapper
11
+ for updating an arbitrary renderable in place.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import sys
18
+
19
+ from sparcli.core import cursor
20
+ from sparcli.core.render import Renderable, Rendered, write_line, write_rendered
21
+ from sparcli.core.terminal import color_support, is_output_tty
22
+
23
+
24
+ class InPlace:
25
+ """Redraws successive frames in the same terminal region."""
26
+
27
+ __slots__ = ("_interactive", "_silent", "_last_height", "_last_frame")
28
+
29
+ def __init__(self, interactive: bool, silent: bool) -> None:
30
+ self._interactive = interactive
31
+ self._silent = silent
32
+ self._last_height = 0
33
+ self._last_frame: Rendered | None = None
34
+
35
+ @classmethod
36
+ def create(cls, always: bool = False) -> InPlace:
37
+ """Returns an engine that redraws on a TTY (or always when forced)."""
38
+ return cls(interactive=always or is_output_tty(), silent=False)
39
+
40
+ @classmethod
41
+ def silent(cls) -> InPlace:
42
+ """Returns an engine that never draws, for headless prompts and tests."""
43
+ return cls(interactive=False, silent=True)
44
+
45
+ def draw(self, rendered: Rendered) -> None:
46
+ """Rewinds and rewrites the frame, or records it when off a terminal."""
47
+ if self._silent:
48
+ return
49
+ if not self._interactive:
50
+ self._last_frame = rendered
51
+ return
52
+ cursor.hide()
53
+ buffer = io.StringIO()
54
+ self._rewind(buffer)
55
+ support = color_support()
56
+ for index, line in enumerate(rendered.lines):
57
+ if index:
58
+ buffer.write("\r\n")
59
+ write_line(buffer, line, support)
60
+ sys.stdout.write(buffer.getvalue())
61
+ sys.stdout.flush()
62
+ self._last_height = rendered.height()
63
+
64
+ def reset(self) -> None:
65
+ """Forgets the previous frame after an external program drew over it."""
66
+ self._last_height = 0
67
+
68
+ def finish(self) -> None:
69
+ """Leaves the final frame in place and ends the session."""
70
+ if self._silent:
71
+ return
72
+ if self._interactive:
73
+ sys.stdout.write("\r\n")
74
+ sys.stdout.flush()
75
+ elif self._last_frame is not None:
76
+ write_rendered(sys.stdout, self._last_frame, color_support())
77
+ sys.stdout.flush()
78
+ cursor.show()
79
+ self._last_height = 0
80
+ self._last_frame = None
81
+
82
+ def clear(self) -> None:
83
+ """Erases the current frame from the terminal."""
84
+ if self._silent or not self._interactive:
85
+ self._last_frame = None
86
+ return
87
+ buffer = io.StringIO()
88
+ self._rewind(buffer)
89
+ sys.stdout.write(buffer.getvalue())
90
+ sys.stdout.flush()
91
+ cursor.show()
92
+ self._last_height = 0
93
+
94
+ def _rewind(self, buffer: io.StringIO) -> None:
95
+ """Writes the escape codes that move the cursor back to the frame top."""
96
+ if not self._last_height:
97
+ return
98
+ buffer.write("\r")
99
+ if self._last_height > 1:
100
+ buffer.write(f"\x1b[{self._last_height - 1}A")
101
+ buffer.write("\x1b[J")
102
+
103
+
104
+ class Live:
105
+ """Updates an arbitrary renderable in place on the terminal."""
106
+
107
+ __slots__ = ("_inplace",)
108
+
109
+ def __init__(self, inplace: InPlace | None = None) -> None:
110
+ self._inplace = inplace or InPlace.create(always=False)
111
+
112
+ @classmethod
113
+ def always(cls) -> Live:
114
+ """Returns a live view that redraws even when off a terminal."""
115
+ return cls(InPlace.create(always=True))
116
+
117
+ def update(self, widget: Renderable) -> None:
118
+ """Renders ``widget`` at the terminal width and draws it in place."""
119
+ from sparcli.core.terminal import term_width
120
+
121
+ self._inplace.draw(widget.render(term_width()))
122
+
123
+ def finish(self) -> None:
124
+ """Leaves the final frame and ends the live session."""
125
+ self._inplace.finish()
126
+
127
+ def clear(self) -> None:
128
+ """Erases the live frame from the terminal."""
129
+ self._inplace.clear()