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,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.password
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Defines :class:`PasswordInput`, a masked single-line secret prompt.
|
|
6
|
+
|
|
7
|
+
The prompt edits one line through the shared :class:`~sparcli.input.line_edit.
|
|
8
|
+
LineEditor` but never shows the typed characters: each is drawn as a mask glyph
|
|
9
|
+
(``*`` by default), or the length is hidden entirely when the mask is empty. It
|
|
10
|
+
validates and filters input like :class:`~sparcli.input.text.TextInput` but
|
|
11
|
+
omits history, suggestions and selection, and returns an
|
|
12
|
+
:class:`~sparcli.input.outcome.Outcome` carrying the submitted secret.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from sparcli.core.render import Rendered
|
|
21
|
+
from sparcli.core.style import Style
|
|
22
|
+
from sparcli.core.terminal import is_input_tty
|
|
23
|
+
from sparcli.core.theme import theme
|
|
24
|
+
from sparcli.errors import NoTerminalError
|
|
25
|
+
from sparcli.input.event import (
|
|
26
|
+
EventKind,
|
|
27
|
+
EventSource,
|
|
28
|
+
InputEvent,
|
|
29
|
+
KeyCode,
|
|
30
|
+
KeyKind,
|
|
31
|
+
KeyPress,
|
|
32
|
+
TerminalSource,
|
|
33
|
+
)
|
|
34
|
+
from sparcli.input.field import error_line, field_line, value_line
|
|
35
|
+
from sparcli.input.guard import TerminalGuard
|
|
36
|
+
from sparcli.input.line_edit import LineEditor
|
|
37
|
+
from sparcli.input.outcome import Outcome
|
|
38
|
+
from sparcli.input.prompt import Flow, run_prompt
|
|
39
|
+
from sparcli.input.validate import CharFilter, Validator
|
|
40
|
+
|
|
41
|
+
# Default mask glyph shown for each typed character.
|
|
42
|
+
_DEFAULT_MASK = "*"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class _State:
|
|
47
|
+
"""Mutable state of a running password prompt."""
|
|
48
|
+
|
|
49
|
+
editor: LineEditor
|
|
50
|
+
error: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Ctrl-letter editing actions dispatched on the shared line editor.
|
|
54
|
+
_CTRL_ACTIONS: dict[str, Callable[[LineEditor], None]] = {
|
|
55
|
+
"u": LineEditor.kill_to_line_start,
|
|
56
|
+
"w": LineEditor.delete_word_back,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PasswordInput:
|
|
61
|
+
"""
|
|
62
|
+
A masked password input prompt.
|
|
63
|
+
|
|
64
|
+
Configure a prompt either through the keyword-only constructor options or
|
|
65
|
+
the matching fluent setters (each mutates and returns ``self``, so calls
|
|
66
|
+
chain), then run it with :meth:`run`. The typed value is displayed as
|
|
67
|
+
repeated mask glyphs; an empty :meth:`mask` hides the length entirely.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
__slots__ = (
|
|
71
|
+
"_prompt",
|
|
72
|
+
"_initial",
|
|
73
|
+
"_mask",
|
|
74
|
+
"_max_chars",
|
|
75
|
+
"_validator",
|
|
76
|
+
"_char_filter",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
prompt: str,
|
|
82
|
+
*,
|
|
83
|
+
initial: str = "",
|
|
84
|
+
mask: str = _DEFAULT_MASK,
|
|
85
|
+
max_chars: int = 0,
|
|
86
|
+
validator: Validator | None = None,
|
|
87
|
+
char_filter: CharFilter | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._prompt: str = prompt
|
|
90
|
+
self._initial: str = initial
|
|
91
|
+
self._mask: str = mask
|
|
92
|
+
self._max_chars: int = max_chars
|
|
93
|
+
self._validator: Validator | None = validator
|
|
94
|
+
self._char_filter: CharFilter | None = char_filter
|
|
95
|
+
|
|
96
|
+
def initial(self, value: str) -> PasswordInput:
|
|
97
|
+
"""Sets an initial value (mainly useful for previews)."""
|
|
98
|
+
self._initial = value
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def mask(self, mask: str) -> PasswordInput:
|
|
102
|
+
"""Sets the mask glyph. An empty mask hides the length entirely."""
|
|
103
|
+
self._mask = mask
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def max_chars(self, maximum: int) -> PasswordInput:
|
|
107
|
+
"""Limits the number of characters (0 = unlimited)."""
|
|
108
|
+
self._max_chars = maximum
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def validate(self, validator: Validator) -> PasswordInput:
|
|
112
|
+
"""Sets a full-value validator and returns ``self``."""
|
|
113
|
+
self._validator = validator
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def char_filter(self, char_filter: CharFilter) -> PasswordInput:
|
|
117
|
+
"""Sets a per-character filter and returns ``self``."""
|
|
118
|
+
self._char_filter = char_filter
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def run(self) -> Outcome[str]:
|
|
122
|
+
"""
|
|
123
|
+
Runs the prompt on the real terminal.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
Outcome[str]
|
|
128
|
+
The submitted secret or a cancellation.
|
|
129
|
+
|
|
130
|
+
Raises
|
|
131
|
+
------
|
|
132
|
+
NoTerminalError
|
|
133
|
+
When there is no interactive terminal.
|
|
134
|
+
"""
|
|
135
|
+
if not is_input_tty():
|
|
136
|
+
raise NoTerminalError()
|
|
137
|
+
with TerminalGuard():
|
|
138
|
+
return self.run_with(TerminalSource())
|
|
139
|
+
|
|
140
|
+
def run_with(self, source: EventSource) -> Outcome[str]:
|
|
141
|
+
"""Runs the prompt against any event source (the test seam)."""
|
|
142
|
+
state = _State(editor=LineEditor(self._initial), error=None)
|
|
143
|
+
return run_prompt(source, state, self._render, self._handle)
|
|
144
|
+
|
|
145
|
+
def frame(self) -> Rendered:
|
|
146
|
+
"""Renders the static initial frame without running (for previews)."""
|
|
147
|
+
state = _State(editor=LineEditor(self._initial), error=None)
|
|
148
|
+
return self._render(state, False)
|
|
149
|
+
|
|
150
|
+
def _render(self, state: _State, final: bool) -> Rendered:
|
|
151
|
+
"""Builds the prompt frame with the masked value."""
|
|
152
|
+
active_theme = theme()
|
|
153
|
+
display, cursor = self._masked(state)
|
|
154
|
+
if final:
|
|
155
|
+
line = value_line(self._prompt, display, Style.new(), active_theme)
|
|
156
|
+
return Rendered([line])
|
|
157
|
+
lines = [
|
|
158
|
+
field_line(self._prompt, display, cursor, Style.new(), active_theme)
|
|
159
|
+
]
|
|
160
|
+
if state.error is not None:
|
|
161
|
+
lines.append(error_line(state.error, active_theme))
|
|
162
|
+
return Rendered(lines)
|
|
163
|
+
|
|
164
|
+
def _masked(self, state: _State) -> tuple[str, int]:
|
|
165
|
+
"""Returns the masked display string and cursor index."""
|
|
166
|
+
if not self._mask:
|
|
167
|
+
return ("", 0)
|
|
168
|
+
glyph = self._mask[0]
|
|
169
|
+
return (glyph * len(state.editor), state.editor.cursor)
|
|
170
|
+
|
|
171
|
+
def _handle(self, state: _State, event: InputEvent) -> Flow[str]:
|
|
172
|
+
"""Handles one input event."""
|
|
173
|
+
if event.kind is EventKind.PASTE and event.text is not None:
|
|
174
|
+
for ch in event.text:
|
|
175
|
+
self._type_char(state, ch)
|
|
176
|
+
return Flow[str].cont()
|
|
177
|
+
if event.kind is EventKind.KEY and event.key is not None:
|
|
178
|
+
return self._handle_key(state, event.key)
|
|
179
|
+
return Flow[str].cont()
|
|
180
|
+
|
|
181
|
+
def _handle_key(self, state: _State, key: KeyPress) -> Flow[str]:
|
|
182
|
+
"""Handles a single key press."""
|
|
183
|
+
if key.ctrl:
|
|
184
|
+
self._handle_ctrl(state, key)
|
|
185
|
+
return Flow[str].cont()
|
|
186
|
+
code = key.code
|
|
187
|
+
if code == KeyCode.ESC:
|
|
188
|
+
return Flow[str].cancel()
|
|
189
|
+
if code == KeyCode.ENTER:
|
|
190
|
+
return self._submit(state)
|
|
191
|
+
self._edit_key(state, code)
|
|
192
|
+
return Flow[str].cont()
|
|
193
|
+
|
|
194
|
+
def _edit_key(self, state: _State, code: KeyCode) -> None:
|
|
195
|
+
"""Applies a non-terminal editing or navigation key."""
|
|
196
|
+
if code == KeyCode.LEFT:
|
|
197
|
+
state.editor.move_left(select=False)
|
|
198
|
+
elif code == KeyCode.RIGHT:
|
|
199
|
+
state.editor.move_right(select=False)
|
|
200
|
+
elif code == KeyCode.BACKSPACE:
|
|
201
|
+
state.editor.backspace()
|
|
202
|
+
elif code == KeyCode.DELETE:
|
|
203
|
+
state.editor.delete()
|
|
204
|
+
elif code.kind is KeyKind.CHAR and code.ch is not None:
|
|
205
|
+
self._type_char(state, code.ch)
|
|
206
|
+
|
|
207
|
+
def _handle_ctrl(self, state: _State, key: KeyPress) -> None:
|
|
208
|
+
"""Handles Ctrl-modified editing keys."""
|
|
209
|
+
code = key.code
|
|
210
|
+
if code.kind is not KeyKind.CHAR or code.ch is None:
|
|
211
|
+
return
|
|
212
|
+
action = _CTRL_ACTIONS.get(code.ch)
|
|
213
|
+
if action is not None:
|
|
214
|
+
action(state.editor)
|
|
215
|
+
|
|
216
|
+
def _submit(self, state: _State) -> Flow[str]:
|
|
217
|
+
"""Validates and submits the current value."""
|
|
218
|
+
value = state.editor.value()
|
|
219
|
+
if self._validator is not None:
|
|
220
|
+
message = self._validator(value)
|
|
221
|
+
if message is not None:
|
|
222
|
+
state.error = message
|
|
223
|
+
return Flow[str].cont()
|
|
224
|
+
return Flow[str].submit(value)
|
|
225
|
+
|
|
226
|
+
def _type_char(self, state: _State, ch: str) -> None:
|
|
227
|
+
"""Types one character if it passes the filter and length limit."""
|
|
228
|
+
if self._char_filter is not None and not self._char_filter(ch):
|
|
229
|
+
return
|
|
230
|
+
if self._max_chars > 0 and len(state.editor) >= self._max_chars:
|
|
231
|
+
return
|
|
232
|
+
state.editor.insert_char(ch)
|
|
233
|
+
state.error = None
|
sparcli/input/prompt.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.prompt
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines the shared prompt driver: render a frame, read an event, repeat.
|
|
6
|
+
|
|
7
|
+
:func:`run_prompt` centralizes the event loop so each prompt supplies only a
|
|
8
|
+
render callback and an event handler. The handler returns a :class:`Flow`
|
|
9
|
+
telling the loop what to do next: keep going, submit a value, cancel, fire a
|
|
10
|
+
shortcut, or refresh after an external program drew over the screen. Drawing
|
|
11
|
+
goes through :class:`~sparcli.output.live.InPlace`, so the loop is a no-op for
|
|
12
|
+
non-interactive sources.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import cast
|
|
21
|
+
|
|
22
|
+
from sparcli.core.render import Rendered
|
|
23
|
+
from sparcli.input.event import EventSource, InputEvent
|
|
24
|
+
from sparcli.input.outcome import Outcome
|
|
25
|
+
from sparcli.output.live import InPlace
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FlowKind(enum.Enum):
|
|
29
|
+
"""What a prompt's event handler decides after each event."""
|
|
30
|
+
|
|
31
|
+
CONTINUE = enum.auto()
|
|
32
|
+
SUBMIT = enum.auto()
|
|
33
|
+
CANCEL = enum.auto()
|
|
34
|
+
SHORTCUT = enum.auto()
|
|
35
|
+
REFRESH = enum.auto()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True, match_args=False)
|
|
39
|
+
class Flow[T]:
|
|
40
|
+
"""
|
|
41
|
+
The next step a prompt loop takes after handling an event.
|
|
42
|
+
|
|
43
|
+
Attributes
|
|
44
|
+
----------
|
|
45
|
+
kind : FlowKind
|
|
46
|
+
Which control-flow decision this represents.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
kind: FlowKind
|
|
50
|
+
_value: object = None
|
|
51
|
+
_id: int | None = None
|
|
52
|
+
|
|
53
|
+
__match_args__ = ("kind",)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def cont(cls) -> Flow[T]:
|
|
57
|
+
"""Returns a flow that keeps the prompt open and redraws."""
|
|
58
|
+
return cls(FlowKind.CONTINUE)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def submit(cls, value: T) -> Flow[T]:
|
|
62
|
+
"""Returns a flow that finishes with a submitted value."""
|
|
63
|
+
return cls(FlowKind.SUBMIT, value)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def cancel(cls) -> Flow[T]:
|
|
67
|
+
"""Returns a flow that finishes as cancelled."""
|
|
68
|
+
return cls(FlowKind.CANCEL)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def shortcut(cls, shortcut_id: int) -> Flow[T]:
|
|
72
|
+
"""Returns a flow that finishes because a shortcut fired."""
|
|
73
|
+
return cls(FlowKind.SHORTCUT, None, shortcut_id)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def refresh(cls) -> Flow[T]:
|
|
77
|
+
"""Returns a flow that keeps open but redraws from scratch."""
|
|
78
|
+
return cls(FlowKind.REFRESH)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def value(self) -> T:
|
|
82
|
+
"""Returns the submitted value; only valid for a submit flow."""
|
|
83
|
+
return cast("T", self._value)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def shortcut_id(self) -> int | None:
|
|
87
|
+
"""Returns the fired shortcut id, or ``None`` for non-shortcut flows."""
|
|
88
|
+
return self._id
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_prompt[S, T](
|
|
92
|
+
source: EventSource,
|
|
93
|
+
state: S,
|
|
94
|
+
render: Callable[[S, bool], Rendered],
|
|
95
|
+
handle: Callable[[S, InputEvent], Flow[T]],
|
|
96
|
+
) -> Outcome[T]:
|
|
97
|
+
"""
|
|
98
|
+
Runs a prompt loop over ``source``, driving ``render`` and ``handle``.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
source : EventSource
|
|
103
|
+
Where events come from; its interactivity selects the draw engine.
|
|
104
|
+
state : S
|
|
105
|
+
The prompt state, mutated in place by ``handle``.
|
|
106
|
+
render : Callable[[S, bool], Rendered]
|
|
107
|
+
Builds the current frame. Its ``bool`` argument is ``True`` only for
|
|
108
|
+
the final frame drawn after submission.
|
|
109
|
+
handle : Callable[[S, InputEvent], Flow[T]]
|
|
110
|
+
Consumes one event and returns the next :class:`Flow`.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
Outcome[T]
|
|
115
|
+
The submitted value, a cancellation, or a fired shortcut.
|
|
116
|
+
"""
|
|
117
|
+
inplace = InPlace.create() if source.is_interactive() else InPlace.silent()
|
|
118
|
+
while True:
|
|
119
|
+
inplace.draw(render(state, False))
|
|
120
|
+
flow = handle(state, source.next_event())
|
|
121
|
+
if flow.kind is FlowKind.CONTINUE:
|
|
122
|
+
continue
|
|
123
|
+
if flow.kind is FlowKind.REFRESH:
|
|
124
|
+
inplace.reset()
|
|
125
|
+
continue
|
|
126
|
+
if flow.kind is FlowKind.SUBMIT:
|
|
127
|
+
inplace.draw(render(state, True))
|
|
128
|
+
inplace.finish()
|
|
129
|
+
return Outcome.submitted(flow.value)
|
|
130
|
+
if flow.kind is FlowKind.SHORTCUT:
|
|
131
|
+
inplace.finish()
|
|
132
|
+
return Outcome.shortcut(flow.shortcut_id or 0)
|
|
133
|
+
inplace.finish()
|
|
134
|
+
return Outcome.cancelled()
|
sparcli/input/select.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.select
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Select`, the single- and multi-selection list prompt.
|
|
6
|
+
|
|
7
|
+
A :class:`Select` shows a scrollable list of options and lets the user move a
|
|
8
|
+
cursor, optionally toggle checkboxes (multi-select) and submit. It follows the
|
|
9
|
+
shared prompt pattern: a kwargs constructor plus fluent builders, a
|
|
10
|
+
:meth:`Select.run` guarded on an interactive terminal, and a
|
|
11
|
+
:meth:`Select.run_with` that drives any :class:`~sparcli.input.event.EventSource`
|
|
12
|
+
for headless tests. Navigation cycles by default and the visible window scrolls
|
|
13
|
+
to keep the cursor on screen.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from sparcli.core.render import Rendered
|
|
21
|
+
from sparcli.core.style import Style
|
|
22
|
+
from sparcli.core.terminal import is_input_tty
|
|
23
|
+
from sparcli.core.text import Line, Span
|
|
24
|
+
from sparcli.core.theme import Theme, theme
|
|
25
|
+
from sparcli.errors import NoTerminalError
|
|
26
|
+
from sparcli.input.event import (
|
|
27
|
+
EventKind,
|
|
28
|
+
EventSource,
|
|
29
|
+
InputEvent,
|
|
30
|
+
KeyCode,
|
|
31
|
+
KeyPress,
|
|
32
|
+
TerminalSource,
|
|
33
|
+
)
|
|
34
|
+
from sparcli.input.guard import TerminalGuard
|
|
35
|
+
from sparcli.input.outcome import Outcome
|
|
36
|
+
from sparcli.input.prompt import Flow, run_prompt
|
|
37
|
+
from sparcli.input.shortcut import Shortcut, find, help_overlay, hint_line
|
|
38
|
+
|
|
39
|
+
# Default number of visible rows.
|
|
40
|
+
DEFAULT_VISIBLE = 10
|
|
41
|
+
|
|
42
|
+
# The prompt collects indices, so every flow it produces carries this type.
|
|
43
|
+
_Flow = Flow[list[int]]
|
|
44
|
+
|
|
45
|
+
# Character keys handled specially by the prompt.
|
|
46
|
+
_VIM_UP = KeyCode.char("k")
|
|
47
|
+
_VIM_DOWN = KeyCode.char("j")
|
|
48
|
+
_SPACE = KeyCode.char(" ")
|
|
49
|
+
_HELP = KeyCode.char("?")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _State:
|
|
53
|
+
"""The mutable state of a running select prompt."""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("cursor", "checked", "offset", "show_help")
|
|
56
|
+
|
|
57
|
+
def __init__(self, cursor: int, checked: list[bool], offset: int) -> None:
|
|
58
|
+
self.cursor = cursor
|
|
59
|
+
self.checked = checked
|
|
60
|
+
self.offset = offset
|
|
61
|
+
self.show_help = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Select:
|
|
65
|
+
"""
|
|
66
|
+
A scrollable selection list, single- or multi-select.
|
|
67
|
+
|
|
68
|
+
Attributes
|
|
69
|
+
----------
|
|
70
|
+
prompt : str
|
|
71
|
+
The label shown above the list.
|
|
72
|
+
options : Iterable[str]
|
|
73
|
+
The selectable rows.
|
|
74
|
+
multi : bool
|
|
75
|
+
Whether checkboxes and multi-selection are enabled.
|
|
76
|
+
max_visible : int
|
|
77
|
+
The maximum number of rows shown at once.
|
|
78
|
+
cycle : bool
|
|
79
|
+
Whether cursor navigation wraps around at the ends.
|
|
80
|
+
shortcuts : Iterable[Shortcut]
|
|
81
|
+
Custom shortcuts shown in the footer and the ``?`` help overlay.
|
|
82
|
+
initial_cursor : int
|
|
83
|
+
The row highlighted when the prompt opens.
|
|
84
|
+
initial_checked : Iterable[int]
|
|
85
|
+
The rows checked when a multi-select prompt opens.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
__slots__ = (
|
|
89
|
+
"_prompt",
|
|
90
|
+
"_options",
|
|
91
|
+
"_multi",
|
|
92
|
+
"_max_visible",
|
|
93
|
+
"_cycle",
|
|
94
|
+
"_shortcuts",
|
|
95
|
+
"_initial_cursor",
|
|
96
|
+
"_initial_checked",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
prompt: str,
|
|
102
|
+
*,
|
|
103
|
+
options: Iterable[str] = (),
|
|
104
|
+
multi: bool = False,
|
|
105
|
+
max_visible: int = DEFAULT_VISIBLE,
|
|
106
|
+
cycle: bool = True,
|
|
107
|
+
shortcuts: Iterable[Shortcut] = (),
|
|
108
|
+
initial_cursor: int = 0,
|
|
109
|
+
initial_checked: Iterable[int] = (),
|
|
110
|
+
) -> None:
|
|
111
|
+
self._prompt = prompt
|
|
112
|
+
self._options = list(options)
|
|
113
|
+
self._multi = multi
|
|
114
|
+
self._max_visible = max(max_visible, 1)
|
|
115
|
+
self._cycle = cycle
|
|
116
|
+
self._shortcuts = list(shortcuts)
|
|
117
|
+
self._initial_cursor = initial_cursor
|
|
118
|
+
self._initial_checked = list(initial_checked)
|
|
119
|
+
|
|
120
|
+
def options(self, options: Iterable[str]) -> Select:
|
|
121
|
+
"""Sets the selectable rows and returns ``self``."""
|
|
122
|
+
self._options = list(options)
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def multi(self) -> Select:
|
|
126
|
+
"""Enables multi-selection with checkboxes and returns ``self``."""
|
|
127
|
+
self._multi = True
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def max_visible(self, rows: int) -> Select:
|
|
131
|
+
"""Sets the maximum number of visible rows and returns ``self``."""
|
|
132
|
+
self._max_visible = max(rows, 1)
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def no_cycle(self) -> Select:
|
|
136
|
+
"""Disables wrap-around navigation and returns ``self``."""
|
|
137
|
+
self._cycle = False
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def cursor(self, index: int) -> Select:
|
|
141
|
+
"""Sets the initially highlighted row and returns ``self``."""
|
|
142
|
+
self._initial_cursor = index
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def checked(self, indices: Iterable[int]) -> Select:
|
|
146
|
+
"""Sets the initially checked rows and returns ``self``."""
|
|
147
|
+
self._initial_checked = list(indices)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def shortcuts(self, shortcuts: Iterable[Shortcut]) -> Select:
|
|
151
|
+
"""Registers custom shortcuts and returns ``self``."""
|
|
152
|
+
self._shortcuts = list(shortcuts)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def run(self) -> Outcome[int]:
|
|
156
|
+
"""
|
|
157
|
+
Runs a single-select prompt and returns the chosen index.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
Outcome[int]
|
|
162
|
+
The submitted index, a cancellation, or a fired shortcut.
|
|
163
|
+
|
|
164
|
+
Raises
|
|
165
|
+
------
|
|
166
|
+
NoTerminalError
|
|
167
|
+
If standard input or output is not an interactive terminal.
|
|
168
|
+
"""
|
|
169
|
+
return _first_index(self._run_collect())
|
|
170
|
+
|
|
171
|
+
def run_multi(self) -> Outcome[list[int]]:
|
|
172
|
+
"""
|
|
173
|
+
Runs a multi-select prompt and returns all checked indices.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
Outcome[list[int]]
|
|
178
|
+
The submitted indices, a cancellation, or a fired shortcut.
|
|
179
|
+
|
|
180
|
+
Raises
|
|
181
|
+
------
|
|
182
|
+
NoTerminalError
|
|
183
|
+
If standard input or output is not an interactive terminal.
|
|
184
|
+
"""
|
|
185
|
+
return self._run_collect()
|
|
186
|
+
|
|
187
|
+
def run_with(self, source: EventSource) -> Outcome[list[int]]:
|
|
188
|
+
"""
|
|
189
|
+
Runs the prompt against ``source`` and returns the checked indices.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
source : EventSource
|
|
194
|
+
The event source driving the prompt (a fake in tests).
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
Outcome[list[int]]
|
|
199
|
+
The submitted indices, a cancellation, or a fired shortcut.
|
|
200
|
+
"""
|
|
201
|
+
if not self._options:
|
|
202
|
+
empty: list[int] = []
|
|
203
|
+
return Outcome.submitted(empty)
|
|
204
|
+
state = self._initial_state()
|
|
205
|
+
return run_prompt(source, state, self._render, self._handle)
|
|
206
|
+
|
|
207
|
+
def frame(self) -> Rendered:
|
|
208
|
+
"""Renders the opening frame without running the prompt."""
|
|
209
|
+
return self._render(self._initial_state(), False)
|
|
210
|
+
|
|
211
|
+
def _run_collect(self) -> Outcome[list[int]]:
|
|
212
|
+
"""Sets up the terminal and runs the prompt loop."""
|
|
213
|
+
if not is_input_tty():
|
|
214
|
+
raise NoTerminalError
|
|
215
|
+
with TerminalGuard():
|
|
216
|
+
return self.run_with(TerminalSource())
|
|
217
|
+
|
|
218
|
+
def _initial_state(self) -> _State:
|
|
219
|
+
"""Builds the starting state from the initial cursor and checks."""
|
|
220
|
+
length = len(self._options)
|
|
221
|
+
cursor = min(self._initial_cursor, max(length - 1, 0))
|
|
222
|
+
checked = [False] * length
|
|
223
|
+
for index in self._initial_checked:
|
|
224
|
+
if 0 <= index < length:
|
|
225
|
+
checked[index] = True
|
|
226
|
+
offset = max(cursor - (self._max_visible - 1), 0)
|
|
227
|
+
return _State(cursor=cursor, checked=checked, offset=offset)
|
|
228
|
+
|
|
229
|
+
def _render(self, state: _State, _final: bool) -> Rendered:
|
|
230
|
+
"""Builds the frame with the visible window of options."""
|
|
231
|
+
active = theme()
|
|
232
|
+
if state.show_help:
|
|
233
|
+
return Rendered(help_overlay(self._shortcuts))
|
|
234
|
+
lines = [Line.styled(self._prompt, active.title)]
|
|
235
|
+
end = min(state.offset + self._max_visible, len(self._options))
|
|
236
|
+
for index in range(state.offset, end):
|
|
237
|
+
lines.append(self._option_line(state, index, active))
|
|
238
|
+
if self._shortcuts:
|
|
239
|
+
lines.append(hint_line(self._shortcuts))
|
|
240
|
+
return Rendered(lines)
|
|
241
|
+
|
|
242
|
+
def _option_line(self, state: _State, index: int, active: Theme) -> Line:
|
|
243
|
+
"""Renders one option row."""
|
|
244
|
+
is_cursor = index == state.cursor
|
|
245
|
+
marker = active.cursor_marker() if is_cursor else active.marker()
|
|
246
|
+
spans = [Span.styled(marker, active.selection)]
|
|
247
|
+
if self._multi:
|
|
248
|
+
checkbox = (
|
|
249
|
+
active.checkbox_on()
|
|
250
|
+
if state.checked[index]
|
|
251
|
+
else active.checkbox_off()
|
|
252
|
+
)
|
|
253
|
+
spans.append(Span.raw(checkbox))
|
|
254
|
+
style = active.selection if is_cursor else Style.new()
|
|
255
|
+
spans.append(Span.styled(self._options[index], style))
|
|
256
|
+
return Line(spans)
|
|
257
|
+
|
|
258
|
+
def _handle(self, state: _State, event: InputEvent) -> _Flow:
|
|
259
|
+
"""Handles one input event."""
|
|
260
|
+
if event.kind is not EventKind.KEY or event.key is None:
|
|
261
|
+
return _Flow.cont()
|
|
262
|
+
return self._handle_key(state, event.key)
|
|
263
|
+
|
|
264
|
+
def _handle_key(self, state: _State, key: KeyPress) -> _Flow:
|
|
265
|
+
"""Handles a single key press."""
|
|
266
|
+
if state.show_help:
|
|
267
|
+
state.show_help = False
|
|
268
|
+
return _Flow.cont()
|
|
269
|
+
if key.code == _HELP and self._shortcuts:
|
|
270
|
+
state.show_help = True
|
|
271
|
+
return _Flow.cont()
|
|
272
|
+
shortcut_id = find(key, self._shortcuts)
|
|
273
|
+
if shortcut_id is not None:
|
|
274
|
+
return _Flow.shortcut(shortcut_id)
|
|
275
|
+
if key.code == KeyCode.ESC:
|
|
276
|
+
return _Flow.cancel()
|
|
277
|
+
if key.code == KeyCode.ENTER:
|
|
278
|
+
return _Flow.submit(self._collect(state))
|
|
279
|
+
self._navigate(state, key.code)
|
|
280
|
+
return _Flow.cont()
|
|
281
|
+
|
|
282
|
+
def _navigate(self, state: _State, code: KeyCode) -> None:
|
|
283
|
+
"""Applies a navigation or toggle key to the state."""
|
|
284
|
+
if code in (KeyCode.UP, _VIM_UP):
|
|
285
|
+
self._move_cursor(state, -1)
|
|
286
|
+
elif code in (KeyCode.DOWN, _VIM_DOWN):
|
|
287
|
+
self._move_cursor(state, 1)
|
|
288
|
+
elif code == KeyCode.HOME:
|
|
289
|
+
self._set_cursor(state, 0)
|
|
290
|
+
elif code == KeyCode.END:
|
|
291
|
+
self._set_cursor(state, len(self._options) - 1)
|
|
292
|
+
elif code == KeyCode.PAGE_UP:
|
|
293
|
+
self._move_cursor(state, -self._max_visible)
|
|
294
|
+
elif code == KeyCode.PAGE_DOWN:
|
|
295
|
+
self._move_cursor(state, self._max_visible)
|
|
296
|
+
elif code == _SPACE and self._multi:
|
|
297
|
+
state.checked[state.cursor] = not state.checked[state.cursor]
|
|
298
|
+
|
|
299
|
+
def _collect(self, state: _State) -> list[int]:
|
|
300
|
+
"""Returns the result indices for the current state."""
|
|
301
|
+
if self._multi:
|
|
302
|
+
return [
|
|
303
|
+
index
|
|
304
|
+
for index in range(len(self._options))
|
|
305
|
+
if state.checked[index]
|
|
306
|
+
]
|
|
307
|
+
return [state.cursor]
|
|
308
|
+
|
|
309
|
+
def _move_cursor(self, state: _State, delta: int) -> None:
|
|
310
|
+
"""Moves the cursor by ``delta``, cycling or clamping per config."""
|
|
311
|
+
length = len(self._options)
|
|
312
|
+
if length == 0:
|
|
313
|
+
return
|
|
314
|
+
target = state.cursor + delta
|
|
315
|
+
if self._cycle:
|
|
316
|
+
target %= length
|
|
317
|
+
else:
|
|
318
|
+
target = max(0, min(target, length - 1))
|
|
319
|
+
self._set_cursor(state, target)
|
|
320
|
+
|
|
321
|
+
def _set_cursor(self, state: _State, index: int) -> None:
|
|
322
|
+
"""Sets the cursor and scrolls so it stays visible."""
|
|
323
|
+
state.cursor = index
|
|
324
|
+
if index < state.offset:
|
|
325
|
+
state.offset = index
|
|
326
|
+
elif index >= state.offset + self._max_visible:
|
|
327
|
+
state.offset = index + 1 - self._max_visible
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _first_index(outcome: Outcome[list[int]]) -> Outcome[int]:
|
|
331
|
+
"""Reduces a collected outcome to its first index (single-select)."""
|
|
332
|
+
if outcome.is_shortcut:
|
|
333
|
+
return Outcome.shortcut(outcome.shortcut_id or 0)
|
|
334
|
+
if outcome.is_submitted and outcome.value:
|
|
335
|
+
return Outcome.submitted(outcome.value[0])
|
|
336
|
+
return Outcome.cancelled()
|