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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.textarea
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Textarea`, a multi-line text input prompt.
|
|
6
|
+
|
|
7
|
+
The textarea drives a :class:`~sparcli.input.line_edit.LineEditor` in multi-line
|
|
8
|
+
mode: Enter inserts a newline, Ctrl-D submits the whole buffer and Esc cancels.
|
|
9
|
+
Arrows plus Home/End move the caret (Shift extends a selection), Up/Down move
|
|
10
|
+
between rows preserving the column, and the usual Ctrl editing keys apply. With
|
|
11
|
+
the editor enabled, Ctrl-G opens ``$EDITOR`` on a Markdown temp file and folds
|
|
12
|
+
the result back in. Each buffer line is rendered under the prompt, with a block
|
|
13
|
+
cursor on the caret's line that is dropped on the final frame.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from sparcli.core.render import Rendered
|
|
22
|
+
from sparcli.core.style import Style
|
|
23
|
+
from sparcli.core.terminal import is_input_tty
|
|
24
|
+
from sparcli.core.text import Line
|
|
25
|
+
from sparcli.core.theme import theme
|
|
26
|
+
from sparcli.errors import NoTerminalError, SparcliError
|
|
27
|
+
from sparcli.input.editor import edit_text, suspended_raw_mode
|
|
28
|
+
from sparcli.input.event import (
|
|
29
|
+
EventKind,
|
|
30
|
+
EventSource,
|
|
31
|
+
InputEvent,
|
|
32
|
+
KeyCode,
|
|
33
|
+
KeyKind,
|
|
34
|
+
KeyPress,
|
|
35
|
+
TerminalSource,
|
|
36
|
+
)
|
|
37
|
+
from sparcli.input.field import field_line
|
|
38
|
+
from sparcli.input.guard import TerminalGuard
|
|
39
|
+
from sparcli.input.line_edit import LineEditor
|
|
40
|
+
from sparcli.input.outcome import Outcome
|
|
41
|
+
from sparcli.input.prompt import Flow, run_prompt
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Flow specialized for this prompt, so control constructors bind their type.
|
|
46
|
+
_StrFlow = Flow[str]
|
|
47
|
+
|
|
48
|
+
# Temp-file extension used when editing the buffer in an external editor.
|
|
49
|
+
_EDITOR_SUFFIX = ".md"
|
|
50
|
+
# Ctrl-letter that submits the buffer.
|
|
51
|
+
_SUBMIT_KEY = "d"
|
|
52
|
+
# Ctrl-letter that opens the external editor.
|
|
53
|
+
_EDITOR_KEY = "g"
|
|
54
|
+
|
|
55
|
+
# Ctrl-letter editing operations shared with the single-line text input.
|
|
56
|
+
_CTRL_OPS: dict[str, Callable[[LineEditor], None]] = {
|
|
57
|
+
"a": LineEditor.select_all,
|
|
58
|
+
"w": LineEditor.delete_word_back,
|
|
59
|
+
"u": LineEditor.kill_to_line_start,
|
|
60
|
+
"k": LineEditor.kill_to_line_end,
|
|
61
|
+
"c": LineEditor.copy,
|
|
62
|
+
"x": LineEditor.cut,
|
|
63
|
+
"v": LineEditor.paste,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Textarea:
|
|
68
|
+
"""
|
|
69
|
+
A multi-line text input prompt.
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
prompt : str
|
|
74
|
+
The label shown above the buffer.
|
|
75
|
+
|
|
76
|
+
Examples
|
|
77
|
+
--------
|
|
78
|
+
>>> area = Textarea("Notes").initial("hello")
|
|
79
|
+
>>> area.frame().height()
|
|
80
|
+
2
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
__slots__ = ("_prompt", "_initial", "_editor_enabled", "_editor_command")
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
prompt: str,
|
|
88
|
+
*,
|
|
89
|
+
initial: str = "",
|
|
90
|
+
editor_enabled: bool = False,
|
|
91
|
+
editor_command: str | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._prompt = prompt
|
|
94
|
+
self._initial = initial
|
|
95
|
+
self._editor_enabled = editor_enabled or editor_command is not None
|
|
96
|
+
self._editor_command = editor_command
|
|
97
|
+
|
|
98
|
+
def initial(self, value: str) -> Textarea:
|
|
99
|
+
"""Sets the initial multi-line value and returns ``self``."""
|
|
100
|
+
self._initial = value
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def editor(self) -> Textarea:
|
|
104
|
+
"""Enables opening the buffer in ``$EDITOR`` with Ctrl-G."""
|
|
105
|
+
self._editor_enabled = True
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def editor_command(self, command: str) -> Textarea:
|
|
109
|
+
"""Sets the editor command (implies :meth:`editor`)."""
|
|
110
|
+
self._editor_enabled = True
|
|
111
|
+
self._editor_command = command
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def run(self) -> Outcome[str]:
|
|
115
|
+
"""
|
|
116
|
+
Runs the prompt on the real terminal.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
Outcome[str]
|
|
121
|
+
The submitted text or a cancellation.
|
|
122
|
+
|
|
123
|
+
Raises
|
|
124
|
+
------
|
|
125
|
+
NoTerminalError
|
|
126
|
+
If standard input or output is not an interactive terminal.
|
|
127
|
+
"""
|
|
128
|
+
if not is_input_tty():
|
|
129
|
+
raise NoTerminalError()
|
|
130
|
+
with TerminalGuard():
|
|
131
|
+
return self.run_with(TerminalSource())
|
|
132
|
+
|
|
133
|
+
def run_with(self, source: EventSource) -> Outcome[str]:
|
|
134
|
+
"""
|
|
135
|
+
Runs the prompt against any event source (used for tests).
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
source : EventSource
|
|
140
|
+
The source of input events driving the prompt.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
Outcome[str]
|
|
145
|
+
The prompt result.
|
|
146
|
+
"""
|
|
147
|
+
editor = LineEditor(self._initial, multiline=True)
|
|
148
|
+
return run_prompt(source, editor, self._render, self._handle)
|
|
149
|
+
|
|
150
|
+
def frame(self) -> Rendered:
|
|
151
|
+
"""Renders the prompt's static frame without running it."""
|
|
152
|
+
return self._render(LineEditor(self._initial, multiline=True), False)
|
|
153
|
+
|
|
154
|
+
def _render(self, editor: LineEditor, final_frame: bool) -> Rendered:
|
|
155
|
+
"""Builds the frame, drawing the cursor on its line."""
|
|
156
|
+
active = theme()
|
|
157
|
+
cursor_line, cursor_col = editor.cursor_line_col()
|
|
158
|
+
lines = [Line.styled(self._prompt, active.title)]
|
|
159
|
+
for index, text in enumerate(editor.lines()):
|
|
160
|
+
if index == cursor_line and not final_frame:
|
|
161
|
+
lines.append(
|
|
162
|
+
field_line("", text, cursor_col, Style.new(), active)
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
lines.append(Line.raw(text))
|
|
166
|
+
return Rendered(lines)
|
|
167
|
+
|
|
168
|
+
def _handle(self, editor: LineEditor, event: InputEvent) -> Flow[str]:
|
|
169
|
+
"""Handles one event for the textarea."""
|
|
170
|
+
if event.kind is EventKind.PASTE and event.text is not None:
|
|
171
|
+
editor.insert_str(event.text)
|
|
172
|
+
return _StrFlow.cont()
|
|
173
|
+
if event.kind is not EventKind.KEY or event.key is None:
|
|
174
|
+
return _StrFlow.cont()
|
|
175
|
+
return self._handle_key(editor, event.key)
|
|
176
|
+
|
|
177
|
+
def _handle_key(self, editor: LineEditor, key: KeyPress) -> Flow[str]:
|
|
178
|
+
"""Handles a single key press (delegating Ctrl keys)."""
|
|
179
|
+
if key.ctrl:
|
|
180
|
+
return self._handle_ctrl(editor, key)
|
|
181
|
+
code = key.code
|
|
182
|
+
if code == KeyCode.ESC:
|
|
183
|
+
return _StrFlow.cancel()
|
|
184
|
+
_apply_edit(editor, key)
|
|
185
|
+
return _StrFlow.cont()
|
|
186
|
+
|
|
187
|
+
def _handle_ctrl(self, editor: LineEditor, key: KeyPress) -> Flow[str]:
|
|
188
|
+
"""Handles Ctrl-modified keys (submit, editor and edit ops)."""
|
|
189
|
+
code = key.code
|
|
190
|
+
if code.kind is not KeyKind.CHAR or code.ch is None:
|
|
191
|
+
return _StrFlow.cont()
|
|
192
|
+
ch = code.ch
|
|
193
|
+
if ch == _SUBMIT_KEY:
|
|
194
|
+
return _StrFlow.submit(editor.value())
|
|
195
|
+
if ch == _EDITOR_KEY and self._editor_enabled:
|
|
196
|
+
return self._launch_editor(editor)
|
|
197
|
+
operation = _CTRL_OPS.get(ch)
|
|
198
|
+
if operation is not None:
|
|
199
|
+
operation(editor)
|
|
200
|
+
return _StrFlow.cont()
|
|
201
|
+
|
|
202
|
+
def _launch_editor(self, editor: LineEditor) -> Flow[str]:
|
|
203
|
+
"""Opens the buffer in an external editor, then refreshes."""
|
|
204
|
+
text = _edit_externally(self._editor_command, editor.value())
|
|
205
|
+
if text is not None:
|
|
206
|
+
editor.set_value(text.rstrip("\n"))
|
|
207
|
+
return _StrFlow.refresh()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _apply_edit(editor: LineEditor, key: KeyPress) -> None:
|
|
211
|
+
"""Applies a non-Ctrl editing or movement key to the editor."""
|
|
212
|
+
code = key.code
|
|
213
|
+
if code == KeyCode.ENTER:
|
|
214
|
+
editor.insert_newline()
|
|
215
|
+
elif code == KeyCode.LEFT:
|
|
216
|
+
editor.move_left(select=key.shift)
|
|
217
|
+
elif code == KeyCode.RIGHT:
|
|
218
|
+
editor.move_right(select=key.shift)
|
|
219
|
+
elif code == KeyCode.UP:
|
|
220
|
+
editor.move_up(select=key.shift)
|
|
221
|
+
elif code == KeyCode.DOWN:
|
|
222
|
+
editor.move_down(select=key.shift)
|
|
223
|
+
elif code == KeyCode.HOME:
|
|
224
|
+
editor.move_home(select=key.shift)
|
|
225
|
+
elif code == KeyCode.END:
|
|
226
|
+
editor.move_end(select=key.shift)
|
|
227
|
+
elif code == KeyCode.BACKSPACE:
|
|
228
|
+
editor.backspace()
|
|
229
|
+
elif code == KeyCode.DELETE:
|
|
230
|
+
editor.delete()
|
|
231
|
+
elif code.kind is KeyKind.CHAR and code.ch is not None:
|
|
232
|
+
editor.insert_char(code.ch)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _edit_externally(command: str | None, text: str) -> str | None:
|
|
236
|
+
"""Edits ``text`` externally with raw mode suspended; ``None`` on error."""
|
|
237
|
+
try:
|
|
238
|
+
with suspended_raw_mode():
|
|
239
|
+
return edit_text(command, text, _EDITOR_SUFFIX)
|
|
240
|
+
except SparcliError as error:
|
|
241
|
+
logger.debug("external editor failed: %s", error)
|
|
242
|
+
return None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.validate
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Defines validation callbacks and character filters for text prompts.
|
|
6
|
+
|
|
7
|
+
A :data:`Validator` inspects a whole input value and returns an error message
|
|
8
|
+
(or ``None`` when the value is acceptable). A :data:`CharFilter` decides whether
|
|
9
|
+
a single typed character is allowed. The builtins here cover the common cases:
|
|
10
|
+
non-empty input, minimum length, and digit / decimal / alphabetic /
|
|
11
|
+
alphanumeric / no-space character sets.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
# Returns an error message for a bad value, or ``None`` when it is acceptable.
|
|
19
|
+
Validator = Callable[[str], str | None]
|
|
20
|
+
|
|
21
|
+
# Decides whether a single typed character is accepted.
|
|
22
|
+
CharFilter = Callable[[str], bool]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def non_empty() -> Validator:
|
|
26
|
+
"""Returns a validator that rejects empty (whitespace-only) input."""
|
|
27
|
+
|
|
28
|
+
def validate(value: str) -> str | None:
|
|
29
|
+
return None if value.strip() else "must not be empty"
|
|
30
|
+
|
|
31
|
+
return validate
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def min_len(minimum: int) -> Validator:
|
|
35
|
+
"""Returns a validator requiring at least ``minimum`` characters."""
|
|
36
|
+
|
|
37
|
+
def validate(value: str) -> str | None:
|
|
38
|
+
if len(value) < minimum:
|
|
39
|
+
return f"must be at least {minimum} characters"
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
return validate
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def digits() -> CharFilter:
|
|
46
|
+
"""Returns a filter accepting only ASCII digits."""
|
|
47
|
+
return lambda ch: "0" <= ch <= "9"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def decimal() -> CharFilter:
|
|
51
|
+
"""Returns a filter accepting digits plus a sign and decimal point."""
|
|
52
|
+
return lambda ch: ("0" <= ch <= "9") or ch in {".", "-"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def alpha() -> CharFilter:
|
|
56
|
+
"""Returns a filter accepting only alphabetic characters."""
|
|
57
|
+
return lambda ch: ch.isalpha()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def alnum() -> CharFilter:
|
|
61
|
+
"""Returns a filter accepting only alphanumeric characters."""
|
|
62
|
+
return lambda ch: ch.isalnum()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def no_space() -> CharFilter:
|
|
66
|
+
"""Returns a filter rejecting whitespace."""
|
|
67
|
+
return lambda ch: not ch.isspace()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output
|
|
3
|
+
=============
|
|
4
|
+
|
|
5
|
+
Printable widgets that render styled blocks to the terminal.
|
|
6
|
+
|
|
7
|
+
Every widget implements :class:`~sparcli.core.render.Renderable`. This package
|
|
8
|
+
re-exports the full set for flat imports; the composition helpers
|
|
9
|
+
:func:`align`, :func:`pad` and :func:`vstack` stack and place rendered blocks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from sparcli.output.alert import Alert, AlertKind
|
|
13
|
+
from sparcli.output.badge import Badge
|
|
14
|
+
from sparcli.output.columns import Columns
|
|
15
|
+
from sparcli.output.compose import align, pad, vstack
|
|
16
|
+
from sparcli.output.diff import Diff
|
|
17
|
+
from sparcli.output.kv import KeyValue
|
|
18
|
+
from sparcli.output.list import List, Marker
|
|
19
|
+
from sparcli.output.live import Live
|
|
20
|
+
from sparcli.output.multiprogress import MultiProgress
|
|
21
|
+
from sparcli.output.pager import Pager
|
|
22
|
+
from sparcli.output.panel import Panel
|
|
23
|
+
from sparcli.output.progress import ProgressBar, ProgressStyle, Thresholds
|
|
24
|
+
from sparcli.output.rule import Rule
|
|
25
|
+
from sparcli.output.spinner import Spinner, SpinnerStyle
|
|
26
|
+
from sparcli.output.table import Cell, Column, Table
|
|
27
|
+
from sparcli.output.tree import Tree, TreeNode
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Alert",
|
|
31
|
+
"AlertKind",
|
|
32
|
+
"Badge",
|
|
33
|
+
"Cell",
|
|
34
|
+
"Column",
|
|
35
|
+
"Columns",
|
|
36
|
+
"Diff",
|
|
37
|
+
"KeyValue",
|
|
38
|
+
"List",
|
|
39
|
+
"Live",
|
|
40
|
+
"Marker",
|
|
41
|
+
"MultiProgress",
|
|
42
|
+
"Pager",
|
|
43
|
+
"Panel",
|
|
44
|
+
"ProgressBar",
|
|
45
|
+
"ProgressStyle",
|
|
46
|
+
"Rule",
|
|
47
|
+
"Spinner",
|
|
48
|
+
"SpinnerStyle",
|
|
49
|
+
"Table",
|
|
50
|
+
"Thresholds",
|
|
51
|
+
"Tree",
|
|
52
|
+
"TreeNode",
|
|
53
|
+
"align",
|
|
54
|
+
"pad",
|
|
55
|
+
"vstack",
|
|
56
|
+
]
|
sparcli/output/alert.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.alert
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Alert`, a bordered callout with a semantic icon and color.
|
|
6
|
+
|
|
7
|
+
Each :class:`AlertKind` maps to a theme style (info, warning, error, ...) and a
|
|
8
|
+
two-step icon (Unicode with an ASCII fallback). The border and icon share the
|
|
9
|
+
kind's accent color. Convenience constructors cover the common kinds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
|
|
16
|
+
from sparcli.core.render import Renderable, Rendered
|
|
17
|
+
from sparcli.core.style import Style
|
|
18
|
+
from sparcli.core.text import IntoText, Line, Span, Text, into_text
|
|
19
|
+
from sparcli.core.theme import Theme, theme
|
|
20
|
+
from sparcli.output.box import BoxOpts, draw_box
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AlertKind(enum.Enum):
|
|
24
|
+
"""The semantic kind of an alert."""
|
|
25
|
+
|
|
26
|
+
INFO = enum.auto()
|
|
27
|
+
DEBUG = enum.auto()
|
|
28
|
+
WARNING = enum.auto()
|
|
29
|
+
ERROR = enum.auto()
|
|
30
|
+
SUCCESS = enum.auto()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Per-kind (unicode icon, ascii icon).
|
|
34
|
+
_ICONS: dict[AlertKind, tuple[str, str]] = {
|
|
35
|
+
AlertKind.INFO: ("ℹ", "i"),
|
|
36
|
+
AlertKind.DEBUG: ("⚙", "*"),
|
|
37
|
+
AlertKind.WARNING: ("⚠", "!"),
|
|
38
|
+
AlertKind.ERROR: ("✖", "x"),
|
|
39
|
+
AlertKind.SUCCESS: ("✔", "+"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _kind_style(kind: AlertKind, active: Theme) -> Style:
|
|
44
|
+
"""Returns the theme style for an alert kind."""
|
|
45
|
+
styles: dict[AlertKind, Style] = {
|
|
46
|
+
AlertKind.INFO: active.info,
|
|
47
|
+
AlertKind.DEBUG: active.debug,
|
|
48
|
+
AlertKind.WARNING: active.warning,
|
|
49
|
+
AlertKind.ERROR: active.error,
|
|
50
|
+
AlertKind.SUCCESS: active.success,
|
|
51
|
+
}
|
|
52
|
+
return styles[kind]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Alert(Renderable):
|
|
56
|
+
"""A bordered callout carrying a semantic icon and color."""
|
|
57
|
+
|
|
58
|
+
__slots__ = ("_kind", "_content")
|
|
59
|
+
|
|
60
|
+
def __init__(self, kind: AlertKind, content: IntoText) -> None:
|
|
61
|
+
self._kind = kind
|
|
62
|
+
self._content = into_text(content)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def info(cls, content: IntoText) -> Alert:
|
|
66
|
+
"""Returns an info alert."""
|
|
67
|
+
return cls(AlertKind.INFO, content)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def debug(cls, content: IntoText) -> Alert:
|
|
71
|
+
"""Returns a debug alert."""
|
|
72
|
+
return cls(AlertKind.DEBUG, content)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def warning(cls, content: IntoText) -> Alert:
|
|
76
|
+
"""Returns a warning alert."""
|
|
77
|
+
return cls(AlertKind.WARNING, content)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def error(cls, content: IntoText) -> Alert:
|
|
81
|
+
"""Returns an error alert."""
|
|
82
|
+
return cls(AlertKind.ERROR, content)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def success(cls, content: IntoText) -> Alert:
|
|
86
|
+
"""Returns a success alert."""
|
|
87
|
+
return cls(AlertKind.SUCCESS, content)
|
|
88
|
+
|
|
89
|
+
def render(self, max_width: int) -> Rendered:
|
|
90
|
+
"""Renders the alert into at most ``max_width`` columns."""
|
|
91
|
+
active = theme()
|
|
92
|
+
style = _kind_style(self._kind, active)
|
|
93
|
+
icon = _ICONS[self._kind][0 if active.unicode else 1]
|
|
94
|
+
content = _prefix_icon(self._content, icon, style)
|
|
95
|
+
opts = BoxOpts(
|
|
96
|
+
border=active.border,
|
|
97
|
+
border_style=style,
|
|
98
|
+
)
|
|
99
|
+
return draw_box(Rendered.from_text(content), opts, max_width)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _prefix_icon(content: Text, icon: str, style: Style) -> Text:
|
|
103
|
+
"""Prepends a styled icon to the first line of ``content``."""
|
|
104
|
+
lines = list(content.lines) or [Line()]
|
|
105
|
+
first = lines[0]
|
|
106
|
+
prefixed = Line([Span.styled(f"{icon} ", style), *first.spans])
|
|
107
|
+
return Text([prefixed, *lines[1:]])
|
sparcli/output/badge.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.badge
|
|
3
|
+
====================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Badge`, a small inline token such as ``[TAG]`` or ``(v1.0)``.
|
|
6
|
+
|
|
7
|
+
A badge wraps a short text in a pair of caps and paints it in a single style,
|
|
8
|
+
defaulting to the accent color in bold. It renders to one line and also exposes
|
|
9
|
+
:meth:`Badge.span`, so it can be embedded inline in other lines. Both a keyword
|
|
10
|
+
constructor and fluent builder methods are provided, matching :class:`Panel`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from sparcli.core.render import Renderable, Rendered
|
|
16
|
+
from sparcli.core.style import Style
|
|
17
|
+
from sparcli.core.text import Line, Span
|
|
18
|
+
from sparcli.core.theme import theme
|
|
19
|
+
|
|
20
|
+
_DEFAULT_LEFT_CAP = "["
|
|
21
|
+
_DEFAULT_RIGHT_CAP = "]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Badge(Renderable):
|
|
25
|
+
"""A small inline token with configurable caps and style."""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_text", "_left_cap", "_right_cap", "_style", "_pad")
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
text: str,
|
|
32
|
+
*,
|
|
33
|
+
left_cap: str = _DEFAULT_LEFT_CAP,
|
|
34
|
+
right_cap: str = _DEFAULT_RIGHT_CAP,
|
|
35
|
+
style: Style | None = None,
|
|
36
|
+
pad: int = 0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._text = text
|
|
39
|
+
self._left_cap = left_cap
|
|
40
|
+
self._right_cap = right_cap
|
|
41
|
+
self._style = style if style is not None else _default_style()
|
|
42
|
+
self._pad = pad
|
|
43
|
+
|
|
44
|
+
def caps(self, left: str, right: str) -> Badge:
|
|
45
|
+
"""Sets both caps at once and returns the badge."""
|
|
46
|
+
self._left_cap = left
|
|
47
|
+
self._right_cap = right
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def style(self, style: Style) -> Badge:
|
|
51
|
+
"""Sets the badge text style and returns the badge."""
|
|
52
|
+
self._style = style
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def pad(self, pad: int) -> Badge:
|
|
56
|
+
"""Sets the number of spaces inside the caps and returns the badge."""
|
|
57
|
+
self._pad = pad
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def span(self) -> Span:
|
|
61
|
+
"""
|
|
62
|
+
Returns the badge as a single styled :class:`Span`.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
Span
|
|
67
|
+
The caps and padded text painted in the badge style.
|
|
68
|
+
"""
|
|
69
|
+
spaces = " " * max(0, self._pad)
|
|
70
|
+
content = (
|
|
71
|
+
f"{self._left_cap}{spaces}{self._text}{spaces}{self._right_cap}"
|
|
72
|
+
)
|
|
73
|
+
return Span.styled(content, self._style)
|
|
74
|
+
|
|
75
|
+
def render(self, max_width: int) -> Rendered:
|
|
76
|
+
"""Renders the badge as a single line, ignoring ``max_width``."""
|
|
77
|
+
return Rendered([Line([self.span()])])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _default_style() -> Style:
|
|
81
|
+
"""Returns the default accent-bold badge style from the theme."""
|
|
82
|
+
return Style.new().with_fg(theme().accent).bold()
|