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,123 @@
1
+ """
2
+ sparcli.input.shortcut
3
+ =====================
4
+
5
+ Defines custom keyboard shortcuts and their footer/overlay rendering.
6
+
7
+ A :class:`Shortcut` binds a key to a caller-defined action id and a footer
8
+ label. :func:`find` maps a pressed key to its id; :func:`hint_line` renders the
9
+ compact footer, and :func:`help_overlay` the expanded key list. :func:`key_name`
10
+ turns a key press into a human-readable name such as ``Ctrl-S`` or ``Shift-Tab``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from sparcli.core.style import Style
18
+ from sparcli.core.text import Line, Span
19
+ from sparcli.core.theme import theme
20
+ from sparcli.input.event import KeyCode, KeyKind, KeyPress
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Shortcut:
25
+ """
26
+ A bound shortcut: a key, a caller-defined id and a footer label.
27
+
28
+ Attributes
29
+ ----------
30
+ key : KeyPress
31
+ The key that triggers the action.
32
+ id : int
33
+ The caller-defined action id reported when fired.
34
+ label : str
35
+ The label shown in the footer hint line.
36
+ """
37
+
38
+ key: KeyPress
39
+ id: int
40
+ label: str
41
+
42
+
43
+ def find(key: KeyPress, shortcuts: list[Shortcut]) -> int | None:
44
+ """Returns the id of the shortcut matching ``key``, if any."""
45
+ for shortcut in shortcuts:
46
+ if shortcut.key == key:
47
+ return shortcut.id
48
+ return None
49
+
50
+
51
+ def hint_line(shortcuts: list[Shortcut]) -> Line:
52
+ """Builds a footer hint line: ``key label - key label - ...``."""
53
+ active = theme()
54
+ spans: list[Span] = []
55
+ for index, shortcut in enumerate(shortcuts):
56
+ if index > 0:
57
+ spans.append(Span.styled(" · ", active.secondary))
58
+ spans.append(
59
+ Span.styled(
60
+ key_name(shortcut.key), Style.new().with_fg(active.accent)
61
+ )
62
+ )
63
+ spans.append(Span.styled(f" {shortcut.label}", active.secondary))
64
+ return Line(spans)
65
+
66
+
67
+ def help_overlay(shortcuts: list[Shortcut]) -> list[Line]:
68
+ """Builds help-overlay lines listing each shortcut (key and label)."""
69
+ active = theme()
70
+ lines: list[Line] = [Line.styled("Keys", active.heading)]
71
+ for shortcut in shortcuts:
72
+ lines.append(
73
+ Line(
74
+ [
75
+ Span.styled(
76
+ f" {key_name(shortcut.key)}",
77
+ Style.new().with_fg(active.accent),
78
+ ),
79
+ Span.styled(f" {shortcut.label}", active.secondary),
80
+ ]
81
+ )
82
+ )
83
+ lines.append(Line.styled("press any key to close", active.secondary))
84
+ return lines
85
+
86
+
87
+ def key_name(key: KeyPress) -> str:
88
+ """Returns a human-readable name for a key press."""
89
+ parts: list[str] = []
90
+ if key.ctrl:
91
+ parts.append("Ctrl-")
92
+ if key.alt:
93
+ parts.append("Alt-")
94
+ parts.append(_code_name(key.code))
95
+ return "".join(parts)
96
+
97
+
98
+ _CODE_NAMES: dict[KeyKind, str] = {
99
+ KeyKind.ENTER: "Enter",
100
+ KeyKind.ESC: "Esc",
101
+ KeyKind.TAB: "Tab",
102
+ KeyKind.BACK_TAB: "Shift-Tab",
103
+ KeyKind.BACKSPACE: "Backspace",
104
+ KeyKind.DELETE: "Del",
105
+ KeyKind.UP: "Up",
106
+ KeyKind.DOWN: "Down",
107
+ KeyKind.LEFT: "Left",
108
+ KeyKind.RIGHT: "Right",
109
+ KeyKind.HOME: "Home",
110
+ KeyKind.END: "End",
111
+ KeyKind.PAGE_UP: "PgUp",
112
+ KeyKind.PAGE_DOWN: "PgDn",
113
+ KeyKind.UNKNOWN: "?",
114
+ }
115
+
116
+
117
+ def _code_name(code: KeyCode) -> str:
118
+ """Returns the base name for a key code."""
119
+ if code.kind is KeyKind.CHAR:
120
+ return (code.ch or "").upper()
121
+ if code.kind is KeyKind.FUNCTION:
122
+ return f"F{code.n}"
123
+ return _CODE_NAMES[code.kind]
sparcli/input/text.py ADDED
@@ -0,0 +1,564 @@
1
+ """
2
+ sparcli.input.text
3
+ ==================
4
+
5
+ Defines :class:`TextInput`, a single-line text prompt.
6
+
7
+ The prompt edits one line through the shared :class:`~sparcli.input.line_edit.
8
+ LineEditor`, validates and filters input, recalls history with Up/Down and
9
+ completes suggestions either as dim inline ghost text or as a navigable
10
+ dropdown. It returns an :class:`~sparcli.input.outcome.Outcome` carrying the
11
+ submitted string, or a cancellation. Rendering and the event loop are delegated
12
+ to the shared infrastructure, so this module only wires configuration, state
13
+ and key handling together.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import enum
19
+ import logging
20
+ from collections.abc import Callable, Iterable
21
+ from dataclasses import dataclass
22
+
23
+ from sparcli.core.render import Rendered
24
+ from sparcli.core.style import Style
25
+ from sparcli.core.terminal import is_input_tty
26
+ from sparcli.core.text import Line, Span
27
+ from sparcli.core.theme import Theme, theme
28
+ from sparcli.errors import NoTerminalError, SparcliError
29
+ from sparcli.input.editor import edit_text, suspended_raw_mode
30
+ from sparcli.input.event import (
31
+ EventKind,
32
+ EventSource,
33
+ InputEvent,
34
+ KeyCode,
35
+ KeyKind,
36
+ KeyPress,
37
+ TerminalSource,
38
+ )
39
+ from sparcli.input.field import (
40
+ error_line,
41
+ field_line,
42
+ placeholder_line,
43
+ value_line,
44
+ )
45
+ from sparcli.input.guard import TerminalGuard
46
+ from sparcli.input.history import History
47
+ from sparcli.input.line_edit import LineEditor
48
+ from sparcli.input.outcome import Outcome
49
+ from sparcli.input.prompt import Flow, run_prompt
50
+ from sparcli.input.validate import CharFilter, Validator
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # Maximum number of dropdown rows shown at once.
55
+ MAX_DROPDOWN = 5
56
+
57
+
58
+ class MatchMode(enum.Enum):
59
+ """How suggestions are matched against the typed value."""
60
+
61
+ PREFIX = enum.auto()
62
+ SUBSEQUENCE = enum.auto()
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class _State:
67
+ """Mutable state of a running text prompt."""
68
+
69
+ editor: LineEditor
70
+ error: str | None
71
+ history_index: int | None
72
+ dropdown_index: int | None
73
+ history_entries: list[str]
74
+ store: History | None
75
+
76
+
77
+ # Ctrl-letter editing actions dispatched on the shared line editor.
78
+ _CTRL_ACTIONS: dict[str, Callable[[LineEditor], None]] = {
79
+ "a": LineEditor.select_all,
80
+ "w": LineEditor.delete_word_back,
81
+ "u": LineEditor.kill_to_line_start,
82
+ "k": LineEditor.kill_to_line_end,
83
+ "c": LineEditor.copy,
84
+ "x": LineEditor.cut,
85
+ "v": LineEditor.paste,
86
+ }
87
+
88
+
89
+ class TextInput:
90
+ """
91
+ A single-line text input prompt.
92
+
93
+ Configure a prompt either through the keyword-only constructor options or
94
+ the matching fluent setters (each mutates and returns ``self``, so calls
95
+ chain), then run it with :meth:`run`. The default matching completes
96
+ suggestions by prefix as inline ghost text; :meth:`dropdown` switches to a
97
+ navigable list and :meth:`fuzzy_suggestions` to subsequence matching.
98
+ """
99
+
100
+ __slots__ = (
101
+ "_prompt",
102
+ "_initial",
103
+ "_placeholder",
104
+ "_max_chars",
105
+ "_hide_char_count",
106
+ "_validator",
107
+ "_char_filter",
108
+ "_suggestions",
109
+ "_history",
110
+ "_history_app",
111
+ "_dropdown",
112
+ "_match_mode",
113
+ "_editor_enabled",
114
+ "_editor_command",
115
+ )
116
+
117
+ def __init__(
118
+ self,
119
+ prompt: str,
120
+ *,
121
+ initial: str = "",
122
+ placeholder: str = "",
123
+ max_chars: int = 0,
124
+ hide_char_count: bool = False,
125
+ validator: Validator | None = None,
126
+ char_filter: CharFilter | None = None,
127
+ suggestions: Iterable[str] = (),
128
+ history: Iterable[str] = (),
129
+ history_app: str | None = None,
130
+ dropdown: bool = False,
131
+ fuzzy_suggestions: bool = False,
132
+ editor: bool = False,
133
+ editor_command: str | None = None,
134
+ ) -> None:
135
+ self._prompt: str = prompt
136
+ self._initial: str = initial
137
+ self._placeholder: str = placeholder
138
+ self._max_chars: int = max_chars
139
+ self._hide_char_count: bool = hide_char_count
140
+ self._validator: Validator | None = validator
141
+ self._char_filter: CharFilter | None = char_filter
142
+ self._suggestions: list[str] = list(suggestions)
143
+ self._history: list[str] = list(history)
144
+ self._history_app: str | None = history_app
145
+ self._dropdown: bool = dropdown
146
+ self._match_mode: MatchMode = (
147
+ MatchMode.SUBSEQUENCE if fuzzy_suggestions else MatchMode.PREFIX
148
+ )
149
+ self._editor_enabled: bool = editor or editor_command is not None
150
+ self._editor_command: str | None = editor_command
151
+
152
+ def initial(self, value: str) -> TextInput:
153
+ """Sets the initial value and returns ``self``."""
154
+ self._initial = value
155
+ return self
156
+
157
+ def placeholder(self, value: str) -> TextInput:
158
+ """Sets the placeholder shown when empty and returns ``self``."""
159
+ self._placeholder = value
160
+ return self
161
+
162
+ def max_chars(self, maximum: int) -> TextInput:
163
+ """Limits the number of characters (0 = unlimited)."""
164
+ self._max_chars = maximum
165
+ return self
166
+
167
+ def validate(self, validator: Validator) -> TextInput:
168
+ """Sets a full-value validator and returns ``self``."""
169
+ self._validator = validator
170
+ return self
171
+
172
+ def char_filter(self, char_filter: CharFilter) -> TextInput:
173
+ """Sets a per-character filter and returns ``self``."""
174
+ self._char_filter = char_filter
175
+ return self
176
+
177
+ def suggestions(self, values: Iterable[str]) -> TextInput:
178
+ """Sets autocomplete suggestions (prefix-matched ghost text)."""
179
+ self._suggestions = list(values)
180
+ return self
181
+
182
+ def history(self, entries: Iterable[str]) -> TextInput:
183
+ """
184
+ Provides history entries recalled with Up/Down.
185
+
186
+ History is unavailable while a navigable dropdown is enabled, since
187
+ Up/Down then drive the suggestion list.
188
+ """
189
+ self._history = list(entries)
190
+ return self
191
+
192
+ def dropdown(self) -> TextInput:
193
+ """
194
+ Shows suggestions as a navigable dropdown instead of ghost text.
195
+
196
+ Up/Down move the highlight, Tab/Enter accept it.
197
+ """
198
+ self._dropdown = True
199
+ return self
200
+
201
+ def fuzzy_suggestions(self) -> TextInput:
202
+ """Matches suggestions by subsequence (fuzzy) instead of prefix."""
203
+ self._match_mode = MatchMode.SUBSEQUENCE
204
+ return self
205
+
206
+ def hide_char_count(self) -> TextInput:
207
+ """Hides the ``(n/max)`` counter shown when ``max_chars`` is set."""
208
+ self._hide_char_count = True
209
+ return self
210
+
211
+ def history_app(self, app: str) -> TextInput:
212
+ """
213
+ Persists history under the app's state dir, recalling and auto-adding.
214
+
215
+ Loads previous entries for Up/Down recall and appends the submitted
216
+ value on success. Overrides :meth:`history`.
217
+ """
218
+ self._history_app = app
219
+ return self
220
+
221
+ def editor(self) -> TextInput:
222
+ """Enables opening the value in ``$EDITOR`` with Ctrl-G."""
223
+ self._editor_enabled = True
224
+ return self
225
+
226
+ def editor_command(self, command: str) -> TextInput:
227
+ """Sets the editor command (implies :meth:`editor`)."""
228
+ self._editor_enabled = True
229
+ self._editor_command = command
230
+ return self
231
+
232
+ def run(self) -> Outcome[str]:
233
+ """
234
+ Runs the prompt on the real terminal.
235
+
236
+ Returns
237
+ -------
238
+ Outcome[str]
239
+ The submitted value or a cancellation.
240
+
241
+ Raises
242
+ ------
243
+ NoTerminalError
244
+ When there is no interactive terminal.
245
+ """
246
+ if not is_input_tty():
247
+ raise NoTerminalError()
248
+ with TerminalGuard():
249
+ return self.run_with(TerminalSource())
250
+
251
+ def run_with(self, source: EventSource) -> Outcome[str]:
252
+ """Runs the prompt against any event source (the test seam)."""
253
+ store, entries = self._load_history()
254
+ state = _State(
255
+ editor=LineEditor(self._initial),
256
+ error=None,
257
+ history_index=None,
258
+ dropdown_index=None,
259
+ history_entries=entries,
260
+ store=store,
261
+ )
262
+ return run_prompt(source, state, self._render, self._handle)
263
+
264
+ def frame(self) -> Rendered:
265
+ """Renders the static initial frame without running (for previews)."""
266
+ state = _State(
267
+ editor=LineEditor(self._initial),
268
+ error=None,
269
+ history_index=None,
270
+ dropdown_index=None,
271
+ history_entries=list(self._history),
272
+ store=None,
273
+ )
274
+ return self._render(state, False)
275
+
276
+ def _load_history(self) -> tuple[History | None, list[str]]:
277
+ """Loads the persistent store and the entries used for recall."""
278
+ if self._history_app is None:
279
+ return (None, list(self._history))
280
+ store = History.for_app(self._history_app)
281
+ store.load()
282
+ return (store, store.entries())
283
+
284
+ def _render(self, state: _State, final: bool) -> Rendered:
285
+ """Builds the prompt frame: field line, ghost text and dropdown."""
286
+ active_theme = theme()
287
+ value = state.editor.value()
288
+ if final:
289
+ line = value_line(self._prompt, value, Style.new(), active_theme)
290
+ return Rendered([line])
291
+ lines = self._value_lines(state, value, active_theme)
292
+ self._append_char_count(lines, state, active_theme)
293
+ if self._dropdown:
294
+ self._push_dropdown(lines, state, value, active_theme)
295
+ if state.error is not None:
296
+ lines.append(error_line(state.error, active_theme))
297
+ return Rendered(lines)
298
+
299
+ def _value_lines(
300
+ self, state: _State, value: str, active_theme: Theme
301
+ ) -> list[Line]:
302
+ """Builds the leading placeholder or field line with ghost text."""
303
+ if not value and self._placeholder:
304
+ return [
305
+ placeholder_line(self._prompt, self._placeholder, active_theme)
306
+ ]
307
+ line = field_line(
308
+ self._prompt, value, state.editor.cursor, Style.new(), active_theme
309
+ )
310
+ if not self._dropdown:
311
+ ghost = self._ghost(value)
312
+ if ghost is not None:
313
+ line.spans.append(Span.styled(ghost, active_theme.secondary))
314
+ return [line]
315
+
316
+ def _append_char_count(
317
+ self, lines: list[Line], state: _State, active_theme: Theme
318
+ ) -> None:
319
+ """Appends the ``(n/max)`` counter to the last line, when enabled."""
320
+ if self._max_chars <= 0 or self._hide_char_count or not lines:
321
+ return
322
+ count = f" ({len(state.editor)}/{self._max_chars})"
323
+ lines[-1].spans.append(Span.styled(count, active_theme.secondary))
324
+
325
+ def _push_dropdown(
326
+ self,
327
+ lines: list[Line],
328
+ state: _State,
329
+ value: str,
330
+ active_theme: Theme,
331
+ ) -> None:
332
+ """Appends the dropdown rows for the current matches."""
333
+ matches = self._matches(value)
334
+ for row, index in enumerate(matches[:MAX_DROPDOWN]):
335
+ active = state.dropdown_index == row
336
+ marker = (
337
+ active_theme.cursor_marker()
338
+ if active
339
+ else active_theme.marker()
340
+ )
341
+ style = active_theme.selection if active else active_theme.secondary
342
+ lines.append(
343
+ Line(
344
+ [
345
+ Span.styled(marker, active_theme.selection),
346
+ Span.styled(self._suggestions[index], style),
347
+ ]
348
+ )
349
+ )
350
+
351
+ def _ghost(self, value: str) -> str | None:
352
+ """Returns the ghost completion suffix for ``value``, if any."""
353
+ if not value:
354
+ return None
355
+ for suggestion in self._suggestions:
356
+ if suggestion.startswith(value) and len(suggestion) > len(value):
357
+ return suggestion[len(value) :]
358
+ return None
359
+
360
+ def _matches(self, value: str) -> list[int]:
361
+ """Returns the suggestion indices matching ``value`` (declared order)."""
362
+ if not value:
363
+ return []
364
+ needle = value.lower()
365
+ return [
366
+ index
367
+ for index, suggestion in enumerate(self._suggestions)
368
+ if _matches_suggestion(needle, suggestion, self._match_mode)
369
+ ]
370
+
371
+ def _handle(self, state: _State, event: InputEvent) -> Flow[str]:
372
+ """Handles one input event."""
373
+ if event.kind is EventKind.PASTE and event.text is not None:
374
+ self._insert_filtered(state, event.text)
375
+ return Flow[str].cont()
376
+ if event.kind is EventKind.KEY and event.key is not None:
377
+ return self._handle_key(state, event.key)
378
+ return Flow[str].cont()
379
+
380
+ def _handle_key(self, state: _State, key: KeyPress) -> Flow[str]:
381
+ """Handles a single key press."""
382
+ if key.ctrl:
383
+ return self._handle_ctrl(state, key)
384
+ code = key.code
385
+ if code == KeyCode.ESC:
386
+ return Flow[str].cancel()
387
+ if code == KeyCode.ENTER:
388
+ return self._on_enter(state)
389
+ self._edit_key(state, key)
390
+ return Flow[str].cont()
391
+
392
+ def _edit_key(self, state: _State, key: KeyPress) -> None:
393
+ """Applies a non-terminal editing or navigation key."""
394
+ code = key.code
395
+ if code == KeyCode.TAB:
396
+ self._accept_completion(state)
397
+ elif code == KeyCode.UP:
398
+ self._recall_up(state)
399
+ elif code == KeyCode.DOWN:
400
+ self._recall_down(state)
401
+ elif code == KeyCode.LEFT:
402
+ state.editor.move_left(select=key.shift)
403
+ elif code == KeyCode.RIGHT:
404
+ state.editor.move_right(select=key.shift)
405
+ elif code == KeyCode.HOME:
406
+ state.editor.move_home(select=key.shift)
407
+ elif code == KeyCode.END:
408
+ state.editor.move_end(select=key.shift)
409
+ elif code == KeyCode.BACKSPACE:
410
+ state.editor.backspace()
411
+ state.dropdown_index = None
412
+ elif code == KeyCode.DELETE:
413
+ state.editor.delete()
414
+ state.dropdown_index = None
415
+ elif code.kind is KeyKind.CHAR and code.ch is not None:
416
+ self._type_char(state, code.ch)
417
+
418
+ def _handle_ctrl(self, state: _State, key: KeyPress) -> Flow[str]:
419
+ """Handles Ctrl-modified editing keys."""
420
+ code = key.code
421
+ if code.kind is not KeyKind.CHAR or code.ch is None:
422
+ return Flow[str].cont()
423
+ if code.ch == "g" and self._editor_enabled:
424
+ return self._launch_editor(state)
425
+ action = _CTRL_ACTIONS.get(code.ch)
426
+ if action is not None:
427
+ action(state.editor)
428
+ return Flow[str].cont()
429
+
430
+ def _on_enter(self, state: _State) -> Flow[str]:
431
+ """Enter accepts a highlighted dropdown row, otherwise submits."""
432
+ if self._dropdown and state.dropdown_index is not None:
433
+ self._accept_completion(state)
434
+ return Flow[str].cont()
435
+ return self._submit(state)
436
+
437
+ def _recall_up(self, state: _State) -> None:
438
+ """Up moves the dropdown highlight, else recalls older history."""
439
+ if self._dropdown:
440
+ self._dropdown_move(state, -1)
441
+ else:
442
+ self._history_prev(state)
443
+
444
+ def _recall_down(self, state: _State) -> None:
445
+ """Down moves the dropdown highlight, else recalls newer history."""
446
+ if self._dropdown:
447
+ self._dropdown_move(state, 1)
448
+ else:
449
+ self._history_next(state)
450
+
451
+ def _dropdown_move(self, state: _State, delta: int) -> None:
452
+ """Moves the dropdown highlight, cycling over the current matches."""
453
+ count = min(len(self._matches(state.editor.value())), MAX_DROPDOWN)
454
+ if count == 0:
455
+ state.dropdown_index = None
456
+ return
457
+ current = state.dropdown_index
458
+ if current is None:
459
+ state.dropdown_index = 0 if delta > 0 else count - 1
460
+ else:
461
+ state.dropdown_index = (current + delta) % count
462
+
463
+ def _accept_completion(self, state: _State) -> None:
464
+ """Fills from the highlighted match, or the ghost completion."""
465
+ if not self._dropdown:
466
+ self._accept_ghost(state)
467
+ return
468
+ matches = self._matches(state.editor.value())
469
+ row = state.dropdown_index if state.dropdown_index is not None else 0
470
+ if row < len(matches):
471
+ state.editor.set_value(self._suggestions[matches[row]])
472
+ state.dropdown_index = None
473
+
474
+ def _accept_ghost(self, state: _State) -> None:
475
+ """Accepts the ghost completion, if present."""
476
+ value = state.editor.value()
477
+ ghost = self._ghost(value)
478
+ if ghost is not None:
479
+ state.editor.set_value(f"{value}{ghost}")
480
+
481
+ def _launch_editor(self, state: _State) -> Flow[str]:
482
+ """Opens the value in an external editor, then refreshes the prompt."""
483
+ text = self._edit_value(state.editor.value())
484
+ if text is not None:
485
+ single_line = text.replace("\n", " ").rstrip()
486
+ state.editor.set_value(single_line)
487
+ state.dropdown_index = None
488
+ return Flow[str].refresh()
489
+
490
+ def _edit_value(self, value: str) -> str | None:
491
+ """Round-trips ``value`` through the editor, swallowing failures."""
492
+ try:
493
+ with suspended_raw_mode():
494
+ return edit_text(self._editor_command, value, ".txt")
495
+ except SparcliError as error:
496
+ logger.debug("could not edit value: %s", error)
497
+ return None
498
+
499
+ def _submit(self, state: _State) -> Flow[str]:
500
+ """Validates and submits the current value, persisting history."""
501
+ value = state.editor.value()
502
+ if self._validator is not None:
503
+ message = self._validator(value)
504
+ if message is not None:
505
+ state.error = message
506
+ return Flow[str].cont()
507
+ if state.store is not None:
508
+ state.store.add(value)
509
+ state.store.save()
510
+ return Flow[str].submit(value)
511
+
512
+ def _type_char(self, state: _State, ch: str) -> None:
513
+ """Types one character if it passes the filter and length limit."""
514
+ if self._char_filter is not None and not self._char_filter(ch):
515
+ return
516
+ if self._max_chars > 0 and len(state.editor) >= self._max_chars:
517
+ return
518
+ state.editor.insert_char(ch)
519
+ state.error = None
520
+ state.dropdown_index = None
521
+
522
+ def _insert_filtered(self, state: _State, text: str) -> None:
523
+ """Inserts pasted text, applying the character filter."""
524
+ for ch in text:
525
+ self._type_char(state, ch)
526
+
527
+ def _history_prev(self, state: _State) -> None:
528
+ """Recalls the previous (older) history entry."""
529
+ if not state.history_entries:
530
+ return
531
+ if state.history_index is None:
532
+ index = len(state.history_entries) - 1
533
+ elif state.history_index == 0:
534
+ index = 0
535
+ else:
536
+ index = state.history_index - 1
537
+ state.history_index = index
538
+ state.editor.set_value(state.history_entries[index])
539
+
540
+ def _history_next(self, state: _State) -> None:
541
+ """Recalls the next (newer) entry, clearing past the newest."""
542
+ index = state.history_index
543
+ if index is None:
544
+ return
545
+ if index + 1 < len(state.history_entries):
546
+ state.history_index = index + 1
547
+ state.editor.set_value(state.history_entries[index + 1])
548
+ else:
549
+ state.history_index = None
550
+ state.editor.set_value("")
551
+
552
+
553
+ def _matches_suggestion(needle: str, suggestion: str, mode: MatchMode) -> bool:
554
+ """Returns whether ``suggestion`` matches the lowercase ``needle``."""
555
+ hay = suggestion.lower()
556
+ if mode is MatchMode.PREFIX:
557
+ return hay.startswith(needle)
558
+ return _is_subsequence(needle, hay)
559
+
560
+
561
+ def _is_subsequence(needle: str, hay: str) -> bool:
562
+ """Returns whether all chars of ``needle`` appear in ``hay`` in order."""
563
+ chars = iter(hay)
564
+ return all(target in chars for target in needle)