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