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,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)
|
sparcli/input/number.py
ADDED
|
@@ -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}"
|
sparcli/input/outcome.py
ADDED
|
@@ -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
|