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/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())
|
sparcli/output/layout.py
ADDED
|
@@ -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()
|