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