zettcode-tui 0.4.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.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Multi-line editor with Readline editing, history, and completion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from zettcode.tui import PROMPT
|
|
9
|
+
|
|
10
|
+
from ..core.events import KeyEvent, MouseAction, MouseEvent, PasteEvent, TextEvent
|
|
11
|
+
from ..core.geometry import Constraints, Point, Size
|
|
12
|
+
from ..core.host import Host
|
|
13
|
+
from ..core.widget import Widget
|
|
14
|
+
from ..render import Canvas, Span, Style, truncate
|
|
15
|
+
from ..render.text import character_width, display_width
|
|
16
|
+
from .completion import Completer, CompletionItem
|
|
17
|
+
|
|
18
|
+
Submit: TypeAlias = Callable[[str], bool | None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TextArea(Widget):
|
|
22
|
+
"""Edit multi-line text with the keys a terminal user already expects.
|
|
23
|
+
|
|
24
|
+
Editing follows Emacs conventions: character and word motion, line and word
|
|
25
|
+
killing, a yank buffer, undo, and submission history. Tabs are stored
|
|
26
|
+
expanded so cursor movement, wrapping, and rendering share one width.
|
|
27
|
+
|
|
28
|
+
Shape::
|
|
29
|
+
|
|
30
|
+
> first line <- prompt, drawn on the first row only
|
|
31
|
+
second line wraps onto more rows <- wraps at rect.width
|
|
32
|
+
fourth row scrolls <- past max_height the draft scrolls
|
|
33
|
+
to keep the cursor row visible
|
|
34
|
+
|
|
35
|
+
The prompt shifts only the first row (its width is ``display_width(prompt)``).
|
|
36
|
+
The row count is geometry, not just paint: a draft that wraps asks the app
|
|
37
|
+
for a fresh layout, which is what makes a composer grow in its box.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
NEWLINE_KEYS: tuple[str, ...] = ("alt_enter", "shift_enter")
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
prompt: str = f"{PROMPT} ",
|
|
46
|
+
placeholder: str = "",
|
|
47
|
+
max_height: int = 7,
|
|
48
|
+
completions: Sequence[str] = (),
|
|
49
|
+
completer: Completer | None = None,
|
|
50
|
+
history_limit: int = 200,
|
|
51
|
+
on_submit: Submit | None = None,
|
|
52
|
+
on_change: Callable[[], None] | None = None,
|
|
53
|
+
surface: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Configure the prompt, history, completion source, and callbacks.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
prompt: Drawn before the first row; its width shifts that row's text
|
|
59
|
+
and the cursor, but no other row.
|
|
60
|
+
placeholder: Shown in place of the first row while the draft is empty.
|
|
61
|
+
max_height: Most rows the editor grows to; longer drafts scroll so the
|
|
62
|
+
cursor row stays visible.
|
|
63
|
+
completions: Static candidates used when no ``completer`` is given,
|
|
64
|
+
matched as a prefix of the token before the cursor.
|
|
65
|
+
completer: Called with ``(text, position)`` and returns the candidates
|
|
66
|
+
for the token under the cursor; overrides ``completions``.
|
|
67
|
+
history_limit: How many submissions to keep for Up and Ctrl-R.
|
|
68
|
+
on_submit: Receives the trimmed draft on Enter; returning ``False``
|
|
69
|
+
rejects it, which leaves the draft and the history untouched.
|
|
70
|
+
on_change: Called after every edit that changes the draft.
|
|
71
|
+
surface: Paint a theme-coloured input band: a blank row above and
|
|
72
|
+
below the draft, and the prompt flush with the band's left edge,
|
|
73
|
+
in the column the transcript's own arrows are drawn in. The
|
|
74
|
+
default leaves the editor's existing compact rendering unchanged.
|
|
75
|
+
"""
|
|
76
|
+
super().__init__()
|
|
77
|
+
self.prompt = prompt
|
|
78
|
+
self.placeholder = placeholder
|
|
79
|
+
self.max_height = max(1, max_height)
|
|
80
|
+
self.completions = tuple(completions)
|
|
81
|
+
self.completer = completer
|
|
82
|
+
self.history_limit = max(1, history_limit)
|
|
83
|
+
self.on_submit = on_submit
|
|
84
|
+
self.on_change = on_change
|
|
85
|
+
self.surface = surface
|
|
86
|
+
self.text = ""
|
|
87
|
+
self.position = 0
|
|
88
|
+
self._cursor_screen: Point | None = None
|
|
89
|
+
self._kill_buffer = ""
|
|
90
|
+
self._undo: list[tuple[str, int]] = []
|
|
91
|
+
self._history: list[str] = []
|
|
92
|
+
self._history_index: int | None = None
|
|
93
|
+
self._history_draft = ""
|
|
94
|
+
self._completion_state: tuple[str, tuple[CompletionItem, ...], int] | None = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def focusable(self) -> bool:
|
|
98
|
+
"""Always accept a Tab stop so the composer is ready without a click."""
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def browsing_history(self) -> bool:
|
|
103
|
+
"""Return whether the draft on show is a recalled entry rather than something being written.
|
|
104
|
+
|
|
105
|
+
It is true from the first Up that recalls an entry until the draft is edited or replaced,
|
|
106
|
+
which is what lets a host tell "the reader is walking through what they sent" from "the
|
|
107
|
+
reader typed this": the first is not a reason to offer anything, and a menu that opened
|
|
108
|
+
over a recalled ``/command`` took the arrow keys from the walk and ended it there.
|
|
109
|
+
"""
|
|
110
|
+
return self._history_index is not None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def history(self) -> tuple[str, ...]:
|
|
114
|
+
"""Return the submitted drafts, oldest first."""
|
|
115
|
+
return tuple(self._history)
|
|
116
|
+
|
|
117
|
+
def remember(self, entries: Sequence[str]) -> None:
|
|
118
|
+
"""Put earlier drafts at the start of the history, oldest first, as if they had been submitted before.
|
|
119
|
+
|
|
120
|
+
For an owner that kept its own record, such as a command line that carries over what was typed in
|
|
121
|
+
another session. What has already been submitted stays newest, so Up recalls it first.
|
|
122
|
+
"""
|
|
123
|
+
earlier = [entry for entry in entries if entry.strip()]
|
|
124
|
+
self._history = (earlier + self._history)[-self.history_limit :]
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def completion_menu(self) -> tuple[tuple[CompletionItem, ...], int] | None:
|
|
128
|
+
"""Return the candidates a Tab is cycling through and which one is in the draft, or ``None`` when none is."""
|
|
129
|
+
state = self._completion_state
|
|
130
|
+
return None if state is None else (state[1], state[2])
|
|
131
|
+
|
|
132
|
+
def set_text(self, value: str) -> None:
|
|
133
|
+
"""Replace the draft and put the cursor at the end of it."""
|
|
134
|
+
self.show(value)
|
|
135
|
+
self._leave_history()
|
|
136
|
+
self._changed()
|
|
137
|
+
|
|
138
|
+
def show(self, value: str) -> None:
|
|
139
|
+
"""Put ``value`` in the draft with the cursor at the end of it.
|
|
140
|
+
|
|
141
|
+
Every wholesale replacement goes through here — ``set_text``, a recalled
|
|
142
|
+
history entry — so an editor that presents some drafts differently has
|
|
143
|
+
one place to do it.
|
|
144
|
+
"""
|
|
145
|
+
self.text = value
|
|
146
|
+
self.position = len(value)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def value(self) -> str:
|
|
150
|
+
"""Return the draft as it should be submitted.
|
|
151
|
+
|
|
152
|
+
The default is the draft itself; an editor that stands a placeholder in
|
|
153
|
+
for a large paste puts the real text back here.
|
|
154
|
+
"""
|
|
155
|
+
return self.text
|
|
156
|
+
|
|
157
|
+
def paste(self, text: str) -> None:
|
|
158
|
+
"""Insert pasted text.
|
|
159
|
+
|
|
160
|
+
The default writes it into the draft as it arrived; an editor that wants
|
|
161
|
+
a large paste to stand in for itself overrides this.
|
|
162
|
+
"""
|
|
163
|
+
self._insert(text)
|
|
164
|
+
|
|
165
|
+
def spans_for(self, line: str, start: int, body: Style) -> tuple[Span, ...]:
|
|
166
|
+
"""Return the styled pieces one wrapped row is painted with.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
line: Row text as it will be drawn.
|
|
170
|
+
start: Index in the draft where that row begins, for an editor that
|
|
171
|
+
styles parts of the draft rather than the whole line.
|
|
172
|
+
body: Style the row is drawn with unless a piece says otherwise.
|
|
173
|
+
"""
|
|
174
|
+
return (Span(line, body),)
|
|
175
|
+
|
|
176
|
+
def backspace(self) -> None:
|
|
177
|
+
"""Remove what one Backspace press should remove: one character."""
|
|
178
|
+
if self.position:
|
|
179
|
+
self._delete(self.position - 1, self.position)
|
|
180
|
+
|
|
181
|
+
def clear(self) -> None:
|
|
182
|
+
"""Reset the draft and every transient editing mode."""
|
|
183
|
+
self.text = ""
|
|
184
|
+
self.position = 0
|
|
185
|
+
self._undo.clear()
|
|
186
|
+
self._history_index = None
|
|
187
|
+
self._history_draft = ""
|
|
188
|
+
self._completion_state = None
|
|
189
|
+
# Clearing is a draft change like any other, so an owner that mirrors
|
|
190
|
+
# the draft (a completion menu) hears about it.
|
|
191
|
+
self._changed()
|
|
192
|
+
|
|
193
|
+
def completion_candidates(self) -> tuple[CompletionItem, ...]:
|
|
194
|
+
"""Return the candidates for the token under the cursor."""
|
|
195
|
+
if self.completer is not None:
|
|
196
|
+
return tuple(self.completer(self.text, self.position))
|
|
197
|
+
start = self.token_start()
|
|
198
|
+
token = self.text[start : self.position]
|
|
199
|
+
if not token.startswith("/") or any(character.isspace() for character in token):
|
|
200
|
+
return ()
|
|
201
|
+
return tuple(CompletionItem(value=value) for value in self.completions if value.startswith(token))
|
|
202
|
+
|
|
203
|
+
def token_start(self) -> int:
|
|
204
|
+
"""Return where the word under the cursor begins.
|
|
205
|
+
|
|
206
|
+
A completion token is a run of non-space characters, not a whole line:
|
|
207
|
+
a slash command fills the line, but an ``@`` resource can sit inside a
|
|
208
|
+
sentence, and accepting it must leave the rest of the draft alone.
|
|
209
|
+
"""
|
|
210
|
+
start = self.position
|
|
211
|
+
while start > 0 and not self.text[start - 1].isspace():
|
|
212
|
+
start -= 1
|
|
213
|
+
return start
|
|
214
|
+
|
|
215
|
+
def replace_token(self, value: str, *, suffix: str = "") -> None:
|
|
216
|
+
"""Replace the token under the cursor and land after ``suffix``.
|
|
217
|
+
|
|
218
|
+
Used when a completion is accepted. The word under the cursor gives way
|
|
219
|
+
to the finished command name or resource, and the cursor ends after the
|
|
220
|
+
suffix so the menu closes and the next word can be typed straight away.
|
|
221
|
+
"""
|
|
222
|
+
start = self.token_start()
|
|
223
|
+
self._remember_undo()
|
|
224
|
+
inserted = f"{value}{suffix}"
|
|
225
|
+
self.text = self.text[:start] + inserted + self.text[self.position :]
|
|
226
|
+
self.position = start + len(inserted)
|
|
227
|
+
self._completion_state = None
|
|
228
|
+
self._changed()
|
|
229
|
+
|
|
230
|
+
def preferred_height(self, width: int) -> int:
|
|
231
|
+
"""Return the rows the draft needs, capped at ``max_height``."""
|
|
232
|
+
lines, _ = layout_input(self.text, self.position, max(1, width), self.prompt_width)
|
|
233
|
+
return min(self.max_height, max(1, len(lines) + (2 if self.surface else 0)))
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def prompt_width(self) -> int:
|
|
237
|
+
"""Return the columns the prompt reserves on the first row."""
|
|
238
|
+
return display_width(self.prompt)
|
|
239
|
+
|
|
240
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
241
|
+
"""Claim the full offered width and the draft's height."""
|
|
242
|
+
width = constraints.max_width if constraints.max_width is not None else 80
|
|
243
|
+
height = self.preferred_height(max(1, width))
|
|
244
|
+
return constraints.constrain(Size(width, height))
|
|
245
|
+
|
|
246
|
+
def render(self, canvas: Canvas) -> None:
|
|
247
|
+
"""Paint the visible rows and remember the cell the cursor belongs on."""
|
|
248
|
+
theme = self.theme
|
|
249
|
+
inner_width = max(1, self.rect.width)
|
|
250
|
+
inner_height = max(1, self.rect.height - (2 if self.surface and self.rect.height >= 3 else 0))
|
|
251
|
+
top = 1 if self.surface and self.rect.height >= 3 else 0
|
|
252
|
+
background = theme.surface_alt if self.surface else None
|
|
253
|
+
if self.surface:
|
|
254
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=background))
|
|
255
|
+
lines, (cursor_row, cursor_column) = layout_input(self.text, self.position, inner_width, self.prompt_width)
|
|
256
|
+
first_visible = max(0, cursor_row - inner_height + 1)
|
|
257
|
+
body_style = Style(foreground=theme.text, background=background)
|
|
258
|
+
prompt_style = Style(foreground=theme.accent_bright, background=background, bold=True)
|
|
259
|
+
row_starts = self._row_starts(lines)
|
|
260
|
+
for local_row, line in enumerate(lines[first_visible : first_visible + inner_height]):
|
|
261
|
+
source_row = first_visible + local_row
|
|
262
|
+
x = self.rect.x + (self.prompt_width if source_row == 0 else 0)
|
|
263
|
+
if source_row == 0 and self.prompt:
|
|
264
|
+
canvas.draw_text(
|
|
265
|
+
self.rect.x, self.rect.y + top + local_row, self.prompt, prompt_style, max_width=inner_width
|
|
266
|
+
)
|
|
267
|
+
if not self.text and source_row == 0 and self.placeholder:
|
|
268
|
+
canvas.draw_text(
|
|
269
|
+
x,
|
|
270
|
+
self.rect.y + top + local_row,
|
|
271
|
+
truncate(self.placeholder, max(0, inner_width - self.prompt_width)),
|
|
272
|
+
Style(foreground=theme.muted, background=background),
|
|
273
|
+
max_width=max(0, inner_width - self.prompt_width),
|
|
274
|
+
)
|
|
275
|
+
continue
|
|
276
|
+
room = max(0, inner_width - (self.prompt_width if source_row == 0 else 0))
|
|
277
|
+
canvas.draw_spans(
|
|
278
|
+
x,
|
|
279
|
+
self.rect.y + top + local_row,
|
|
280
|
+
self.spans_for(line, row_starts[source_row], body_style),
|
|
281
|
+
max_width=room,
|
|
282
|
+
)
|
|
283
|
+
cursor_x = self.rect.x + (self.prompt_width if cursor_row == 0 else 0) + cursor_column
|
|
284
|
+
self._cursor_screen = Point(cursor_x, self.rect.y + top + cursor_row - first_visible)
|
|
285
|
+
|
|
286
|
+
def _row_starts(self, lines: Sequence[str]) -> list[int]:
|
|
287
|
+
"""Return the draft index each wrapped row begins at.
|
|
288
|
+
|
|
289
|
+
A row break is a newline when the draft has one there and a wrap
|
|
290
|
+
otherwise, which is how a row keeps its place as the draft changes.
|
|
291
|
+
"""
|
|
292
|
+
starts: list[int] = []
|
|
293
|
+
offset = 0
|
|
294
|
+
for line in lines:
|
|
295
|
+
starts.append(offset)
|
|
296
|
+
offset += len(line)
|
|
297
|
+
if offset < len(self.text) and self.text[offset] == "\n":
|
|
298
|
+
offset += 1
|
|
299
|
+
return starts
|
|
300
|
+
|
|
301
|
+
def cursor(self) -> Point | None:
|
|
302
|
+
"""Show the cursor only while this editor actually has focus."""
|
|
303
|
+
return self._cursor_screen if self.focused else None
|
|
304
|
+
|
|
305
|
+
def handle(self, event, host: Host) -> bool:
|
|
306
|
+
"""Route a key, paste, or click to the matching editing operation."""
|
|
307
|
+
if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
|
|
308
|
+
if event.action is MouseAction.DOWN:
|
|
309
|
+
host.focus(self)
|
|
310
|
+
return True
|
|
311
|
+
return False
|
|
312
|
+
if host.focused_widget() is not self:
|
|
313
|
+
return False
|
|
314
|
+
if isinstance(event, PasteEvent):
|
|
315
|
+
self.paste(event.text)
|
|
316
|
+
return True
|
|
317
|
+
if isinstance(event, TextEvent):
|
|
318
|
+
self._insert(event.text)
|
|
319
|
+
return True
|
|
320
|
+
if not isinstance(event, KeyEvent):
|
|
321
|
+
return False
|
|
322
|
+
if event.key not in ("tab", "backtab"):
|
|
323
|
+
# Any other key ends the completion session, so the next Tab starts
|
|
324
|
+
# a fresh cycle from the token under the cursor instead of resuming.
|
|
325
|
+
self._completion_state = None
|
|
326
|
+
if event.alt:
|
|
327
|
+
return self._handle_alt(event.key)
|
|
328
|
+
if event.key in self.NEWLINE_KEYS:
|
|
329
|
+
self._insert("\n")
|
|
330
|
+
return True
|
|
331
|
+
match event.key:
|
|
332
|
+
case "left" | "ctrl_b":
|
|
333
|
+
self.position = max(0, self.position - 1)
|
|
334
|
+
case "right" | "ctrl_f":
|
|
335
|
+
self.position = min(len(self.text), self.position + 1)
|
|
336
|
+
case "home" | "ctrl_a":
|
|
337
|
+
self.position = self._line_start()
|
|
338
|
+
case "end" | "ctrl_e":
|
|
339
|
+
self.position = self._line_end()
|
|
340
|
+
case "ctrl_home":
|
|
341
|
+
self.position = 0
|
|
342
|
+
case "ctrl_end":
|
|
343
|
+
self.position = len(self.text)
|
|
344
|
+
case "ctrl_left" | "alt_left":
|
|
345
|
+
self.position = previous_word(self.text, self.position)
|
|
346
|
+
case "ctrl_right" | "alt_right":
|
|
347
|
+
self.position = next_word(self.text, self.position)
|
|
348
|
+
case "up":
|
|
349
|
+
if not self._move_vertical(-1):
|
|
350
|
+
self._move_history(-1)
|
|
351
|
+
case "down":
|
|
352
|
+
if not self._move_vertical(1):
|
|
353
|
+
self._move_history(1)
|
|
354
|
+
case "ctrl_p":
|
|
355
|
+
self._move_history(-1)
|
|
356
|
+
case "ctrl_n":
|
|
357
|
+
self._move_history(1)
|
|
358
|
+
case "backspace":
|
|
359
|
+
self.backspace()
|
|
360
|
+
case "delete" | "ctrl_d":
|
|
361
|
+
if self.position < len(self.text):
|
|
362
|
+
self._delete(self.position, self.position + 1)
|
|
363
|
+
case "ctrl_w" | "alt_backspace":
|
|
364
|
+
self._delete(previous_word(self.text, self.position), self.position, kill=True)
|
|
365
|
+
case "ctrl_u":
|
|
366
|
+
self._delete(self._line_start(), self.position, kill=True)
|
|
367
|
+
case "ctrl_k":
|
|
368
|
+
self._delete(self.position, self._line_end(), kill=True)
|
|
369
|
+
case "ctrl_y":
|
|
370
|
+
if self._kill_buffer:
|
|
371
|
+
self._insert(self._kill_buffer)
|
|
372
|
+
case "ctrl_z" | "ctrl_underscore":
|
|
373
|
+
self._undo_once()
|
|
374
|
+
case "ctrl_g" | "escape":
|
|
375
|
+
self._leave_history()
|
|
376
|
+
case "ctrl_r":
|
|
377
|
+
self._reverse_history_search()
|
|
378
|
+
case "tab":
|
|
379
|
+
self._complete(1)
|
|
380
|
+
case "backtab":
|
|
381
|
+
self._complete(-1)
|
|
382
|
+
case "enter":
|
|
383
|
+
self._submit()
|
|
384
|
+
case _:
|
|
385
|
+
return False
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
def submit(self) -> None:
|
|
389
|
+
"""Send the draft as Enter does."""
|
|
390
|
+
self._submit()
|
|
391
|
+
|
|
392
|
+
def _submit(self) -> None:
|
|
393
|
+
"""Offer the trimmed draft to the submit hook, recording it when accepted."""
|
|
394
|
+
value = self.value.strip()
|
|
395
|
+
if not value:
|
|
396
|
+
return
|
|
397
|
+
if self.on_submit is not None and self.on_submit(value) is False:
|
|
398
|
+
return
|
|
399
|
+
self.commit(value)
|
|
400
|
+
|
|
401
|
+
def commit(self, value: str) -> None:
|
|
402
|
+
"""Record ``value`` as submitted and empty the editor.
|
|
403
|
+
|
|
404
|
+
:meth:`_submit` does this when the hook accepts. A hook that has to ask the reader
|
|
405
|
+
something first — which kind of message this is — returns ``False`` so the draft stays
|
|
406
|
+
where it can still be changed, and calls this once the answer is in.
|
|
407
|
+
"""
|
|
408
|
+
if not self._history or self._history[-1] != value:
|
|
409
|
+
self._history.append(value)
|
|
410
|
+
del self._history[: -self.history_limit]
|
|
411
|
+
self.clear()
|
|
412
|
+
|
|
413
|
+
def _insert(self, value: str) -> None:
|
|
414
|
+
"""Insert text at the cursor, expanding tabs and recording undo."""
|
|
415
|
+
if not value:
|
|
416
|
+
return
|
|
417
|
+
value = value.expandtabs(4)
|
|
418
|
+
self._remember_undo()
|
|
419
|
+
self._leave_history()
|
|
420
|
+
self.text = self.text[: self.position] + value + self.text[self.position :]
|
|
421
|
+
self.position += len(value)
|
|
422
|
+
self._changed()
|
|
423
|
+
|
|
424
|
+
def _handle_alt(self, key: str) -> bool:
|
|
425
|
+
"""Handle the Alt-key motion and kill bindings."""
|
|
426
|
+
match key.lower():
|
|
427
|
+
case "b":
|
|
428
|
+
self.position = previous_word(self.text, self.position)
|
|
429
|
+
case "f":
|
|
430
|
+
self.position = next_word(self.text, self.position)
|
|
431
|
+
case "d":
|
|
432
|
+
self._delete(self.position, next_word(self.text, self.position), kill=True)
|
|
433
|
+
case "<":
|
|
434
|
+
self.position = 0
|
|
435
|
+
case ">":
|
|
436
|
+
self.position = len(self.text)
|
|
437
|
+
case _:
|
|
438
|
+
return False
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
def _line_start(self) -> int:
|
|
442
|
+
"""Return the index just after the previous newline."""
|
|
443
|
+
return self.text.rfind("\n", 0, self.position) + 1
|
|
444
|
+
|
|
445
|
+
def _line_end(self) -> int:
|
|
446
|
+
"""Return the next newline, or the end of the text."""
|
|
447
|
+
end = self.text.find("\n", self.position)
|
|
448
|
+
return len(self.text) if end < 0 else end
|
|
449
|
+
|
|
450
|
+
def _move_vertical(self, direction: int) -> bool:
|
|
451
|
+
"""Move one row up or down, keeping the column, and report whether it moved."""
|
|
452
|
+
start = self._line_start()
|
|
453
|
+
column = self.position - start
|
|
454
|
+
if direction < 0:
|
|
455
|
+
if start == 0:
|
|
456
|
+
return False
|
|
457
|
+
previous_end = start - 1
|
|
458
|
+
previous_start = self.text.rfind("\n", 0, previous_end) + 1
|
|
459
|
+
self.position = min(previous_start + column, previous_end)
|
|
460
|
+
return True
|
|
461
|
+
end = self._line_end()
|
|
462
|
+
if end == len(self.text):
|
|
463
|
+
return False
|
|
464
|
+
next_start = end + 1
|
|
465
|
+
next_end = self.text.find("\n", next_start)
|
|
466
|
+
if next_end < 0:
|
|
467
|
+
next_end = len(self.text)
|
|
468
|
+
self.position = min(next_start + column, next_end)
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
def _delete(self, start: int, end: int, *, kill: bool = False) -> None:
|
|
472
|
+
"""Remove a range, optionally storing the removed text in the kill buffer.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
start: First index to remove, clamped to the draft.
|
|
476
|
+
end: Index to stop before, clamped as well; an empty range does nothing.
|
|
477
|
+
kill: Store the removed text so Ctrl-Y can yank it back.
|
|
478
|
+
"""
|
|
479
|
+
start, end = max(0, start), min(len(self.text), end)
|
|
480
|
+
if start >= end:
|
|
481
|
+
return
|
|
482
|
+
self._remember_undo()
|
|
483
|
+
removed = self.text[start:end]
|
|
484
|
+
if kill:
|
|
485
|
+
self._kill_buffer = removed
|
|
486
|
+
self.text = self.text[:start] + self.text[end:]
|
|
487
|
+
self.position = start
|
|
488
|
+
self._leave_history()
|
|
489
|
+
self._changed()
|
|
490
|
+
|
|
491
|
+
def _remember_undo(self) -> None:
|
|
492
|
+
"""Push the current state onto the undo stack, skipping duplicates."""
|
|
493
|
+
state = (self.text, self.position)
|
|
494
|
+
if not self._undo or self._undo[-1] != state:
|
|
495
|
+
self._undo.append(state)
|
|
496
|
+
del self._undo[:-100]
|
|
497
|
+
|
|
498
|
+
def _undo_once(self) -> None:
|
|
499
|
+
"""Restore the most recent undo state."""
|
|
500
|
+
if self._undo:
|
|
501
|
+
self.text, self.position = self._undo.pop()
|
|
502
|
+
self._leave_history()
|
|
503
|
+
self._changed()
|
|
504
|
+
|
|
505
|
+
def _move_history(self, direction: int) -> None:
|
|
506
|
+
"""Walk the submission history, restoring the draft when stepping past the end.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
direction: ``-1`` walks back to older entries, ``1`` forward toward
|
|
510
|
+
the live draft.
|
|
511
|
+
"""
|
|
512
|
+
if not self._history:
|
|
513
|
+
return
|
|
514
|
+
if self._history_index is None:
|
|
515
|
+
if direction > 0:
|
|
516
|
+
return
|
|
517
|
+
self._history_draft = self.text
|
|
518
|
+
self._history_index = len(self._history) - 1
|
|
519
|
+
else:
|
|
520
|
+
next_index = self._history_index + direction
|
|
521
|
+
if next_index >= len(self._history):
|
|
522
|
+
self.show(self._history_draft)
|
|
523
|
+
self._history_index = None
|
|
524
|
+
self._changed()
|
|
525
|
+
return
|
|
526
|
+
self._history_index = max(0, next_index)
|
|
527
|
+
self.show(self._history[self._history_index])
|
|
528
|
+
self._changed()
|
|
529
|
+
|
|
530
|
+
def _leave_history(self) -> None:
|
|
531
|
+
"""Stop browsing history so the next edit starts a fresh draft."""
|
|
532
|
+
self._history_index = None
|
|
533
|
+
|
|
534
|
+
def _reverse_history_search(self) -> None:
|
|
535
|
+
"""Jump to the newest history entry containing the current text."""
|
|
536
|
+
if not self._history:
|
|
537
|
+
return
|
|
538
|
+
query = self.text if self._history_index is None else self._history_draft
|
|
539
|
+
start = len(self._history) if self._history_index is None else self._history_index
|
|
540
|
+
match = next((index for index in range(start - 1, -1, -1) if query in self._history[index]), None)
|
|
541
|
+
if match is None:
|
|
542
|
+
return
|
|
543
|
+
if self._history_index is None:
|
|
544
|
+
self._history_draft = query
|
|
545
|
+
self._history_index = match
|
|
546
|
+
self.text = self._history[match]
|
|
547
|
+
self.position = len(self.text)
|
|
548
|
+
self._changed()
|
|
549
|
+
|
|
550
|
+
def _complete(self, direction: int) -> None:
|
|
551
|
+
"""Cycle the token under the cursor through its completion candidates.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
direction: ``1`` for Tab, ``-1`` for Shift-Tab; both wrap around the
|
|
555
|
+
candidate list.
|
|
556
|
+
"""
|
|
557
|
+
state = self._completion_state
|
|
558
|
+
if state is None:
|
|
559
|
+
candidates = self.completion_candidates()
|
|
560
|
+
if not candidates:
|
|
561
|
+
return
|
|
562
|
+
token = self.text[self.token_start() : self.position]
|
|
563
|
+
# The first Tab must land on the first candidate, so the stored index
|
|
564
|
+
# starts one step behind it.
|
|
565
|
+
state = (token, candidates, -1 if direction > 0 else 0)
|
|
566
|
+
original, candidates, index = state
|
|
567
|
+
index = (index + direction) % len(candidates)
|
|
568
|
+
start = self.token_start()
|
|
569
|
+
current = self.text[start : self.position]
|
|
570
|
+
self._remember_undo()
|
|
571
|
+
value = candidates[index].value
|
|
572
|
+
self.text = self.text[:start] + value + self.text[self.position :]
|
|
573
|
+
self.position += len(value) - len(current)
|
|
574
|
+
self._completion_state = (original, candidates, index)
|
|
575
|
+
if len(candidates) == 1:
|
|
576
|
+
# Nothing to cycle through: the next Tab completes from what is in the draft now, which is how a
|
|
577
|
+
# directory completed to ``src/`` goes on to what is inside it.
|
|
578
|
+
self._completion_state = None
|
|
579
|
+
self._changed()
|
|
580
|
+
|
|
581
|
+
def _changed(self) -> None:
|
|
582
|
+
"""Repaint the editor, re-layout when it grew, and tell the owner.
|
|
583
|
+
|
|
584
|
+
The row count is geometry, not just paint: a draft that wraps onto
|
|
585
|
+
another row needs the boxes around this editor to split their space
|
|
586
|
+
again, so the app is asked for a fresh layout whenever the height at the
|
|
587
|
+
current width changes. Keystrokes that stay on the same rows only
|
|
588
|
+
repaint.
|
|
589
|
+
"""
|
|
590
|
+
self.invalidate()
|
|
591
|
+
if self.app is not None and self.preferred_height(max(1, self.rect.width)) != self.rect.height:
|
|
592
|
+
self.app.request_layout()
|
|
593
|
+
if self.on_change is not None:
|
|
594
|
+
self.on_change()
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def previous_word(value: str, position: int) -> int:
|
|
598
|
+
"""Return the index that starts the word before position."""
|
|
599
|
+
index = position
|
|
600
|
+
while index and value[index - 1].isspace():
|
|
601
|
+
index -= 1
|
|
602
|
+
if index and word_character(value[index - 1]):
|
|
603
|
+
while index and word_character(value[index - 1]):
|
|
604
|
+
index -= 1
|
|
605
|
+
else:
|
|
606
|
+
while index and not value[index - 1].isspace() and not word_character(value[index - 1]):
|
|
607
|
+
index -= 1
|
|
608
|
+
return index
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def next_word(value: str, position: int) -> int:
|
|
612
|
+
"""Return the index that ends the word after position."""
|
|
613
|
+
index = position
|
|
614
|
+
while index < len(value) and value[index].isspace():
|
|
615
|
+
index += 1
|
|
616
|
+
if index < len(value) and word_character(value[index]):
|
|
617
|
+
while index < len(value) and word_character(value[index]):
|
|
618
|
+
index += 1
|
|
619
|
+
else:
|
|
620
|
+
while index < len(value) and not value[index].isspace() and not word_character(value[index]):
|
|
621
|
+
index += 1
|
|
622
|
+
return index
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def word_character(character: str) -> bool:
|
|
626
|
+
"""Return whether a character counts as part of a word."""
|
|
627
|
+
return character.isalnum() or character == "_"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def layout_input(text: str, position: int, width: int, prompt_width: int) -> tuple[list[str], tuple[int, int]]:
|
|
631
|
+
"""Wrap editable text while preserving a source-index cursor position.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
text: Full draft, newlines included.
|
|
635
|
+
position: Cursor as a code-point index into ``text``, not a visual column.
|
|
636
|
+
width: Total row width in cells.
|
|
637
|
+
prompt_width: Cells the prompt occupies on the first row only.
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
The wrapped rows and the cursor as ``(row, column)`` in cell coordinates.
|
|
641
|
+
"""
|
|
642
|
+
width = max(1, width)
|
|
643
|
+
rows = [""]
|
|
644
|
+
row = 0
|
|
645
|
+
column = 0
|
|
646
|
+
cursor = (0, 0)
|
|
647
|
+
for index, character in enumerate(text):
|
|
648
|
+
if index == position:
|
|
649
|
+
available = max(1, width - (prompt_width if row == 0 else 0))
|
|
650
|
+
cursor = (row + 1, 0) if column >= available else (row, column)
|
|
651
|
+
if character == "\n":
|
|
652
|
+
rows.append("")
|
|
653
|
+
row += 1
|
|
654
|
+
column = 0
|
|
655
|
+
continue
|
|
656
|
+
char_width = character_width(character)
|
|
657
|
+
available = max(1, width - (prompt_width if row == 0 else 0))
|
|
658
|
+
if column and column + char_width > available:
|
|
659
|
+
rows.append("")
|
|
660
|
+
row += 1
|
|
661
|
+
column = 0
|
|
662
|
+
rows[row] += character
|
|
663
|
+
column += char_width
|
|
664
|
+
if position == len(text):
|
|
665
|
+
available = max(1, width - (prompt_width if row == 0 else 0))
|
|
666
|
+
if column >= available:
|
|
667
|
+
rows.append("")
|
|
668
|
+
cursor = (row + 1, 0)
|
|
669
|
+
else:
|
|
670
|
+
cursor = (row, column)
|
|
671
|
+
return rows, cursor
|