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,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
+ ]
@@ -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:]])
@@ -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()