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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. 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