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,229 @@
1
+ """
2
+ sparcli.input.line_edit
3
+ ======================
4
+
5
+ Defines :class:`LineEditor`, the shared text-editing core for input widgets.
6
+
7
+ This is the single source of truth for caret movement, selection and edit
8
+ operations behind both single- and multi-line prompts. Widgets own the control
9
+ keys (Enter, Esc, ...); the editor only edits. The clipboard is in-process (no
10
+ system dependency); the terminal's bracketed paste still delivers external text
11
+ through :meth:`LineEditor.insert_str`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+
17
+ class LineEditor:
18
+ """A caret-and-selection text editor over a character buffer."""
19
+
20
+ __slots__ = ("_chars", "_cursor", "_anchor", "_clipboard", "_multiline")
21
+
22
+ def __init__(self, initial: str = "", *, multiline: bool = False) -> None:
23
+ self._chars: list[str] = list(initial)
24
+ self._cursor: int = len(self._chars)
25
+ self._anchor: int | None = None
26
+ self._clipboard: str = ""
27
+ self._multiline: bool = multiline
28
+
29
+ def value(self) -> str:
30
+ """Returns the current text."""
31
+ return "".join(self._chars)
32
+
33
+ def set_value(self, value: str) -> None:
34
+ """Replaces the entire buffer and moves the caret to the end."""
35
+ self._chars = list(value)
36
+ self._cursor = len(self._chars)
37
+ self._anchor = None
38
+
39
+ def __len__(self) -> int:
40
+ """Returns the number of characters."""
41
+ return len(self._chars)
42
+
43
+ @property
44
+ def cursor(self) -> int:
45
+ """Returns the caret position as a character index."""
46
+ return self._cursor
47
+
48
+ def lines(self) -> list[str]:
49
+ """Returns the display lines (split on newlines)."""
50
+ return self.value().split("\n")
51
+
52
+ def cursor_line_col(self) -> tuple[int, int]:
53
+ """Returns the caret's ``(line, column)`` in characters."""
54
+ line = 0
55
+ col = 0
56
+ for ch in self._chars[: self._cursor]:
57
+ if ch == "\n":
58
+ line += 1
59
+ col = 0
60
+ else:
61
+ col += 1
62
+ return (line, col)
63
+
64
+ def selection_range(self) -> tuple[int, int] | None:
65
+ """Returns the selection range as ``(start, end)`` if any."""
66
+ anchor = self._anchor
67
+ if anchor is None or anchor == self._cursor:
68
+ return None
69
+ return (min(anchor, self._cursor), max(anchor, self._cursor))
70
+
71
+ def insert_char(self, ch: str) -> None:
72
+ """Inserts a character, replacing any selection."""
73
+ self._delete_selection()
74
+ self._chars.insert(self._cursor, ch)
75
+ self._cursor += 1
76
+
77
+ def insert_str(self, text: str) -> None:
78
+ """
79
+ Inserts a string (e.g. a paste), replacing any selection.
80
+
81
+ In single-line mode newlines are converted to spaces.
82
+ """
83
+ self._delete_selection()
84
+ for ch in text:
85
+ char = " " if not self._multiline and ch == "\n" else ch
86
+ self._chars.insert(self._cursor, char)
87
+ self._cursor += 1
88
+
89
+ def insert_newline(self) -> None:
90
+ """Inserts a newline (multi-line only; ignored otherwise)."""
91
+ if self._multiline:
92
+ self.insert_char("\n")
93
+
94
+ def backspace(self) -> None:
95
+ """Deletes the character before the caret, or the selection."""
96
+ if self._delete_selection():
97
+ return
98
+ if self._cursor > 0:
99
+ self._cursor -= 1
100
+ del self._chars[self._cursor]
101
+
102
+ def delete(self) -> None:
103
+ """Deletes the character at the caret, or the selection."""
104
+ if self._delete_selection():
105
+ return
106
+ if self._cursor < len(self._chars):
107
+ del self._chars[self._cursor]
108
+
109
+ def move_left(self, *, select: bool) -> None:
110
+ """Moves the caret left by one character."""
111
+ self._update_anchor(select=select)
112
+ if self._cursor > 0:
113
+ self._cursor -= 1
114
+
115
+ def move_right(self, *, select: bool) -> None:
116
+ """Moves the caret right by one character."""
117
+ self._update_anchor(select=select)
118
+ if self._cursor < len(self._chars):
119
+ self._cursor += 1
120
+
121
+ def move_home(self, *, select: bool) -> None:
122
+ """Moves the caret to the start of the current line."""
123
+ self._update_anchor(select=select)
124
+ self._cursor = self._line_start(self._cursor)
125
+
126
+ def move_end(self, *, select: bool) -> None:
127
+ """Moves the caret to the end of the current line."""
128
+ self._update_anchor(select=select)
129
+ self._cursor = self._line_end(self._cursor)
130
+
131
+ def move_up(self, *, select: bool) -> None:
132
+ """Moves the caret up one line, preserving the column."""
133
+ self._update_anchor(select=select)
134
+ start = self._line_start(self._cursor)
135
+ if start == 0:
136
+ return
137
+ col = self._cursor - start
138
+ prev_start = self._line_start(start - 1)
139
+ prev_end = start - 1
140
+ self._cursor = min(prev_start + col, prev_end)
141
+
142
+ def move_down(self, *, select: bool) -> None:
143
+ """Moves the caret down one line, preserving the column."""
144
+ self._update_anchor(select=select)
145
+ end = self._line_end(self._cursor)
146
+ if end >= len(self._chars):
147
+ return
148
+ col = self._cursor - self._line_start(self._cursor)
149
+ next_start = end + 1
150
+ next_end = self._line_end(next_start)
151
+ self._cursor = min(next_start + col, next_end)
152
+
153
+ def select_all(self) -> None:
154
+ """Selects the entire buffer."""
155
+ self._anchor = 0
156
+ self._cursor = len(self._chars)
157
+
158
+ def delete_word_back(self) -> None:
159
+ """Deletes the previous whitespace-delimited word."""
160
+ if self._delete_selection():
161
+ return
162
+ index = self._cursor
163
+ while index > 0 and self._chars[index - 1].isspace():
164
+ index -= 1
165
+ while index > 0 and not self._chars[index - 1].isspace():
166
+ index -= 1
167
+ del self._chars[index : self._cursor]
168
+ self._cursor = index
169
+
170
+ def kill_to_line_start(self) -> None:
171
+ """Deletes from the caret to the start of the current line."""
172
+ start = self._line_start(self._cursor)
173
+ del self._chars[start : self._cursor]
174
+ self._cursor = start
175
+
176
+ def kill_to_line_end(self) -> None:
177
+ """Deletes from the caret to the end of the current line."""
178
+ end = self._line_end(self._cursor)
179
+ del self._chars[self._cursor : end]
180
+
181
+ def copy(self) -> None:
182
+ """Copies the selection to the in-process clipboard."""
183
+ span = self.selection_range()
184
+ if span is not None:
185
+ start, end = span
186
+ self._clipboard = "".join(self._chars[start:end])
187
+
188
+ def cut(self) -> None:
189
+ """Cuts the selection to the in-process clipboard."""
190
+ self.copy()
191
+ self._delete_selection()
192
+
193
+ def paste(self) -> None:
194
+ """Pastes the in-process clipboard at the caret."""
195
+ self.insert_str(self._clipboard)
196
+
197
+ def _update_anchor(self, *, select: bool) -> None:
198
+ """Sets or clears the selection anchor before a movement."""
199
+ if select:
200
+ if self._anchor is None:
201
+ self._anchor = self._cursor
202
+ else:
203
+ self._anchor = None
204
+
205
+ def _delete_selection(self) -> bool:
206
+ """Deletes the current selection; returns whether anything was removed."""
207
+ span = self.selection_range()
208
+ if span is None:
209
+ self._anchor = None
210
+ return False
211
+ start, end = span
212
+ del self._chars[start:end]
213
+ self._cursor = start
214
+ self._anchor = None
215
+ return True
216
+
217
+ def _line_start(self, index: int) -> int:
218
+ """Returns the index of the start of the line containing ``index``."""
219
+ for pos in range(index - 1, -1, -1):
220
+ if self._chars[pos] == "\n":
221
+ return pos + 1
222
+ return 0
223
+
224
+ def _line_end(self, index: int) -> int:
225
+ """Returns the index of the end of the line containing ``index``."""
226
+ for pos in range(index, len(self._chars)):
227
+ if self._chars[pos] == "\n":
228
+ return pos
229
+ return len(self._chars)
@@ -0,0 +1,232 @@
1
+ """
2
+ sparcli.input.number
3
+ ====================
4
+
5
+ Defines :class:`NumberInput`, a numeric prompt with an optional calculator.
6
+
7
+ The prompt edits a single line through :class:`~sparcli.input.line_edit.\
8
+ LineEditor`, accepts only characters that can form a number (or, in calculator
9
+ mode, a full arithmetic expression), and adjusts the value with the Up and Down
10
+ arrows. On submit it parses the field, clamps it to the configured range and
11
+ returns the value in an :class:`~sparcli.input.outcome.Outcome`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from sparcli.core.render import Rendered
17
+ from sparcli.core.style import Style
18
+ from sparcli.core.terminal import is_input_tty
19
+ from sparcli.core.theme import theme
20
+ from sparcli.errors import NoTerminalError
21
+ from sparcli.input.calc import CalcError, evaluate, parse_number
22
+ from sparcli.input.event import (
23
+ EventKind,
24
+ EventSource,
25
+ InputEvent,
26
+ KeyCode,
27
+ KeyKind,
28
+ KeyPress,
29
+ TerminalSource,
30
+ )
31
+ from sparcli.input.field import error_line, field_line, value_line
32
+ from sparcli.input.guard import TerminalGuard
33
+ from sparcli.input.line_edit import LineEditor
34
+ from sparcli.input.outcome import Outcome
35
+ from sparcli.input.prompt import Flow, run_prompt
36
+
37
+ # Characters that may form a plain number.
38
+ _NUMERIC_CHARS = "0123456789.,-"
39
+ # Extra characters accepted only in calculator mode.
40
+ _CALC_CHARS = "+*/() "
41
+
42
+
43
+ class _State:
44
+ """The mutable state of a running number prompt."""
45
+
46
+ __slots__ = ("editor", "error")
47
+
48
+ def __init__(self, editor: LineEditor) -> None:
49
+ self.editor = editor
50
+ self.error: str | None = None
51
+
52
+
53
+ class NumberInput:
54
+ """A numeric input prompt with bounds, step and optional calculator mode."""
55
+
56
+ __slots__ = (
57
+ "_prompt",
58
+ "_initial",
59
+ "_minimum",
60
+ "_maximum",
61
+ "_step",
62
+ "_decimals",
63
+ "_calculator",
64
+ )
65
+
66
+ def __init__(
67
+ self,
68
+ prompt: str = "",
69
+ *,
70
+ initial: float = 0.0,
71
+ minimum: float = float("-inf"),
72
+ maximum: float = float("inf"),
73
+ step: float = 1.0,
74
+ decimals: int = 0,
75
+ calculator: bool = False,
76
+ ) -> None:
77
+ self._prompt = prompt
78
+ self._initial = initial
79
+ self._minimum = minimum
80
+ self._maximum = maximum
81
+ self._step = step
82
+ self._decimals = max(decimals, 0)
83
+ self._calculator = calculator
84
+
85
+ def initial(self, value: float) -> NumberInput:
86
+ """Sets the initial value and returns ``self``."""
87
+ self._initial = value
88
+ return self
89
+
90
+ def range(self, minimum: float, maximum: float) -> NumberInput:
91
+ """Sets the inclusive ``[minimum, maximum]`` bounds and returns self."""
92
+ self._minimum = minimum
93
+ self._maximum = maximum
94
+ return self
95
+
96
+ def step(self, step: float) -> NumberInput:
97
+ """Sets the step used by Up and Down and returns ``self``."""
98
+ self._step = step
99
+ return self
100
+
101
+ def decimals(self, decimals: int) -> NumberInput:
102
+ """Sets the number of decimal places shown and returns ``self``."""
103
+ self._decimals = max(decimals, 0)
104
+ return self
105
+
106
+ def calculator(self) -> NumberInput:
107
+ """Enables calculator expressions (``+ - * / ( )``); returns self."""
108
+ self._calculator = True
109
+ return self
110
+
111
+ def run(self) -> Outcome[float]:
112
+ """
113
+ Runs the prompt on the real terminal.
114
+
115
+ Returns
116
+ -------
117
+ Outcome[float]
118
+ The clamped submitted value or a cancellation.
119
+
120
+ Raises
121
+ ------
122
+ NoTerminalError
123
+ If there is no interactive terminal.
124
+ """
125
+ if not is_input_tty():
126
+ raise NoTerminalError()
127
+ with TerminalGuard():
128
+ return self.run_with(TerminalSource())
129
+
130
+ def run_with(self, source: EventSource) -> Outcome[float]:
131
+ """Runs the prompt against any event source (used for tests)."""
132
+ state = _State(LineEditor(self._format(self._initial)))
133
+ return run_prompt(source, state, self._render, self._handle)
134
+
135
+ def frame(self) -> Rendered:
136
+ """Renders the prompt's static frame without running it."""
137
+ state = _State(LineEditor(self._format(self._initial)))
138
+ return self._render(state, False)
139
+
140
+ def _render(self, state: _State, final: bool) -> Rendered:
141
+ """Builds the prompt frame; ``final`` drops the cursor after submit."""
142
+ active = theme()
143
+ value = state.editor.value()
144
+ if final:
145
+ line = value_line(self._prompt, value, Style.new(), active)
146
+ return Rendered([line])
147
+ lines = [
148
+ field_line(
149
+ self._prompt, value, state.editor.cursor, Style.new(), active
150
+ )
151
+ ]
152
+ if state.error is not None:
153
+ lines.append(error_line(state.error, active))
154
+ return Rendered(lines)
155
+
156
+ def _handle(self, state: _State, event: InputEvent) -> Flow[float]:
157
+ """Handles one input event."""
158
+ if event.kind is not EventKind.KEY or event.key is None:
159
+ return Flow[float].cont()
160
+ return self._handle_key(state, event.key)
161
+
162
+ def _handle_key(self, state: _State, key: KeyPress) -> Flow[float]:
163
+ """Handles a single key press."""
164
+ if key.is_ctrl("u"):
165
+ state.editor.kill_to_line_start()
166
+ return Flow[float].cont()
167
+ code = key.code
168
+ if code == KeyCode.ESC:
169
+ return Flow[float].cancel()
170
+ if code == KeyCode.ENTER:
171
+ return self._submit(state)
172
+ self._edit(state, code)
173
+ return Flow[float].cont()
174
+
175
+ def _edit(self, state: _State, code: KeyCode) -> None:
176
+ """Applies a non-terminal editing or navigation key."""
177
+ editor = state.editor
178
+ if code == KeyCode.UP:
179
+ self._adjust(state, self._step)
180
+ elif code == KeyCode.DOWN:
181
+ self._adjust(state, -self._step)
182
+ elif code == KeyCode.LEFT:
183
+ editor.move_left(select=False)
184
+ elif code == KeyCode.RIGHT:
185
+ editor.move_right(select=False)
186
+ elif code == KeyCode.BACKSPACE:
187
+ editor.backspace()
188
+ elif code == KeyCode.DELETE:
189
+ editor.delete()
190
+ elif code.kind is KeyKind.CHAR and code.ch is not None:
191
+ self._insert(state, code.ch)
192
+
193
+ def _insert(self, state: _State, ch: str) -> None:
194
+ """Inserts ``ch`` if it is a valid input character."""
195
+ if not self._accepts(ch):
196
+ return
197
+ state.editor.insert_char(ch)
198
+ state.error = None
199
+
200
+ def _accepts(self, ch: str) -> bool:
201
+ """Returns whether ``ch`` may be typed into the field."""
202
+ numeric = ch in _NUMERIC_CHARS
203
+ calc = ch in _CALC_CHARS
204
+ return numeric or (self._calculator and calc)
205
+
206
+ def _adjust(self, state: _State, delta: float) -> None:
207
+ """Adjusts the current value by ``delta`` and reformats the field."""
208
+ try:
209
+ current = parse_number(state.editor.value())
210
+ except CalcError:
211
+ current = 0.0
212
+ value = self._clamp(current + delta)
213
+ state.editor.set_value(self._format(value))
214
+ state.error = None
215
+
216
+ def _submit(self, state: _State) -> Flow[float]:
217
+ """Evaluates the field and submits the clamped value, or shows error."""
218
+ text = state.editor.value()
219
+ try:
220
+ value = evaluate(text) if self._calculator else parse_number(text)
221
+ except CalcError as error:
222
+ state.error = str(error)
223
+ return Flow[float].cont()
224
+ return Flow[float].submit(self._clamp(value))
225
+
226
+ def _clamp(self, value: float) -> float:
227
+ """Clamps ``value`` to the configured range."""
228
+ return max(self._minimum, min(value, self._maximum))
229
+
230
+ def _format(self, value: float) -> str:
231
+ """Formats ``value`` with the configured number of decimals."""
232
+ return f"{value:.{self._decimals}f}"
@@ -0,0 +1,107 @@
1
+ """
2
+ sparcli.input.outcome
3
+ =====================
4
+
5
+ Defines :class:`Outcome`, the result type returned by every interactive prompt.
6
+
7
+ A prompt never signals its result with an exception or ``None``: it returns an
8
+ :class:`Outcome` that is either a submitted value, a cancellation, or a fired
9
+ shortcut. The kind is carried by :class:`OutcomeKind`; the submitted value is
10
+ reached through :attr:`Outcome.value` (guarded) and the fired shortcut through
11
+ :attr:`Outcome.shortcut_id`. ``__match_args__`` exposes the kind for structural
12
+ pattern matching.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ from dataclasses import dataclass
19
+ from typing import cast
20
+
21
+ from sparcli.errors import SparcliError
22
+
23
+
24
+ class OutcomeKind(enum.Enum):
25
+ """The three ways an interactive prompt can end."""
26
+
27
+ SUBMITTED = enum.auto()
28
+ CANCELLED = enum.auto()
29
+ SHORTCUT = enum.auto()
30
+
31
+
32
+ @dataclass(frozen=True, slots=True, match_args=False)
33
+ class Outcome[T]:
34
+ """
35
+ The result of running an interactive prompt.
36
+
37
+ Attributes
38
+ ----------
39
+ kind : OutcomeKind
40
+ Which of the three terminal states the prompt reached.
41
+ """
42
+
43
+ kind: OutcomeKind
44
+ _value: object = None
45
+ _shortcut_id: int | None = None
46
+
47
+ __match_args__ = ("kind",)
48
+
49
+ @classmethod
50
+ def submitted[U](cls, value: U) -> Outcome[U]:
51
+ """Returns a submitted outcome carrying ``value``."""
52
+ return Outcome[U](OutcomeKind.SUBMITTED, value)
53
+
54
+ @classmethod
55
+ def cancelled[U](cls) -> Outcome[U]:
56
+ """Returns a cancelled outcome."""
57
+ return Outcome[U](OutcomeKind.CANCELLED)
58
+
59
+ @classmethod
60
+ def shortcut[U](cls, shortcut_id: int) -> Outcome[U]:
61
+ """Returns a shortcut outcome carrying the fired action id."""
62
+ return Outcome[U](OutcomeKind.SHORTCUT, None, shortcut_id)
63
+
64
+ @property
65
+ def is_submitted(self) -> bool:
66
+ """Returns whether the prompt ended with a submitted value."""
67
+ return self.kind is OutcomeKind.SUBMITTED
68
+
69
+ @property
70
+ def is_cancelled(self) -> bool:
71
+ """Returns whether the prompt was cancelled."""
72
+ return self.kind is OutcomeKind.CANCELLED
73
+
74
+ @property
75
+ def is_shortcut(self) -> bool:
76
+ """Returns whether the prompt ended on a registered shortcut."""
77
+ return self.kind is OutcomeKind.SHORTCUT
78
+
79
+ @property
80
+ def value(self) -> T:
81
+ """
82
+ Returns the submitted value.
83
+
84
+ Returns
85
+ -------
86
+ T
87
+ The value the user submitted.
88
+
89
+ Raises
90
+ ------
91
+ SparcliError
92
+ If the outcome is not a submission.
93
+ """
94
+ if self.kind is not OutcomeKind.SUBMITTED:
95
+ raise SparcliError("outcome carries no submitted value")
96
+ return cast("T", self._value)
97
+
98
+ @property
99
+ def shortcut_id(self) -> int | None:
100
+ """Returns the fired shortcut id, or ``None`` for other outcomes."""
101
+ return self._shortcut_id
102
+
103
+ def submitted_or(self, default: T) -> T:
104
+ """Returns the submitted value, or ``default`` for other outcomes."""
105
+ if self.kind is OutcomeKind.SUBMITTED:
106
+ return cast("T", self._value)
107
+ return default