zettcode 0.1.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.
@@ -0,0 +1,821 @@
1
+ """Composable terminal widgets with no rendering side effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, replace
7
+ from time import monotonic
8
+ from typing import Protocol
9
+
10
+ from wcwidth import wcwidth
11
+
12
+ from .events import EventType, InputEvent, MouseAction
13
+ from .screen import DEFAULT_STYLE, Canvas, Span, Style, TextLine
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Rect:
18
+ x: int
19
+ y: int
20
+ width: int
21
+ height: int
22
+
23
+ def contains(self, x: int, y: int) -> bool:
24
+ return self.x <= x < self.x + self.width and self.y <= y < self.y + self.height
25
+
26
+
27
+ class UIActions(Protocol):
28
+ def focus(self, component: Component) -> None: ...
29
+
30
+ def invalidate(self) -> None: ...
31
+
32
+ def copy(self, text: str) -> None: ...
33
+
34
+ def refresh(self) -> None: ...
35
+
36
+ def exit(self) -> None: ...
37
+
38
+
39
+ class Component:
40
+ """Base class for layout, painting, input, and cursor ownership."""
41
+
42
+ def __init__(self) -> None:
43
+ self.rect = Rect(0, 0, 0, 0)
44
+
45
+ def layout(self, rect: Rect) -> None:
46
+ self.rect = rect
47
+
48
+ def render(self, canvas: Canvas) -> None:
49
+ pass
50
+
51
+ def handle(self, event: InputEvent, actions: UIActions) -> bool:
52
+ return False
53
+
54
+ def cursor(self) -> tuple[int, int] | None:
55
+ return None
56
+
57
+ def preferred_height(self, width: int) -> int:
58
+ return 1
59
+
60
+
61
+ class Text(Component):
62
+ """Single-line dynamic label."""
63
+
64
+ def __init__(self, value: str | Callable[[], str], style: Style = DEFAULT_STYLE, *, align: str = "left") -> None:
65
+ super().__init__()
66
+ self.value = value
67
+ self.style = style
68
+ self.align = align
69
+
70
+ def render(self, canvas: Canvas) -> None:
71
+ value = self.value() if callable(self.value) else self.value
72
+ width = sum(_character_width(character) for character in value)
73
+ x = self.rect.x + max(0, self.rect.width - width) if self.align == "right" else self.rect.x
74
+ canvas.draw_text(x, self.rect.y, value, self.style, max_width=self.rect.width)
75
+
76
+
77
+ class Rule(Component):
78
+ """Horizontal separator."""
79
+
80
+ def __init__(self, style: Style = DEFAULT_STYLE, character: str = "─") -> None:
81
+ super().__init__()
82
+ self.style = style
83
+ self.character = character
84
+
85
+ def render(self, canvas: Canvas) -> None:
86
+ canvas.draw_text(self.rect.x, self.rect.y, self.character * self.rect.width, self.style)
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class BoxChild:
91
+ component: Component
92
+ size: int | Callable[[int], int] | None = None
93
+ flex: int = 0
94
+
95
+
96
+ class VBox(Component):
97
+ """Vertical layout with fixed, dynamic, and flexible children."""
98
+
99
+ def __init__(self, children: Sequence[BoxChild]) -> None:
100
+ super().__init__()
101
+ self.children = tuple(children)
102
+
103
+ def layout(self, rect: Rect) -> None:
104
+ super().layout(rect)
105
+ fixed = [self._size(child, rect.width) for child in self.children]
106
+ remaining = max(0, rect.height - sum(fixed))
107
+ flex_total = sum(child.flex for child in self.children)
108
+ y = rect.y
109
+ distributed = 0
110
+ for index, child in enumerate(self.children):
111
+ height = fixed[index]
112
+ if child.flex and flex_total:
113
+ height += remaining * child.flex // flex_total
114
+ distributed += remaining * child.flex // flex_total
115
+ if index == len(self.children) - 1:
116
+ height += remaining - distributed
117
+ child.component.layout(Rect(rect.x, y, rect.width, max(0, height)))
118
+ y += height
119
+
120
+ def render(self, canvas: Canvas) -> None:
121
+ for child in self.children:
122
+ child.component.render(canvas)
123
+
124
+ def handle(self, event: InputEvent, actions: UIActions) -> bool:
125
+ return any(child.component.handle(event, actions) for child in self.children)
126
+
127
+ def cursor(self) -> tuple[int, int] | None:
128
+ for child in self.children:
129
+ cursor = child.component.cursor()
130
+ if cursor is not None:
131
+ return cursor
132
+ return None
133
+
134
+ @staticmethod
135
+ def _size(child: BoxChild, width: int) -> int:
136
+ if callable(child.size):
137
+ return max(0, child.size(width))
138
+ if child.size is not None:
139
+ return max(0, child.size)
140
+ return 0
141
+
142
+
143
+ class HBox(Component):
144
+ """Horizontal layout for compact header and status rows."""
145
+
146
+ def __init__(self, children: Sequence[BoxChild]) -> None:
147
+ super().__init__()
148
+ self.children = tuple(children)
149
+
150
+ def layout(self, rect: Rect) -> None:
151
+ super().layout(rect)
152
+ fixed = [self._size(child, rect.height) for child in self.children]
153
+ remaining = max(0, rect.width - sum(fixed))
154
+ flex_total = sum(child.flex for child in self.children)
155
+ x = rect.x
156
+ distributed = 0
157
+ for index, child in enumerate(self.children):
158
+ width = fixed[index]
159
+ if child.flex and flex_total:
160
+ width += remaining * child.flex // flex_total
161
+ distributed += remaining * child.flex // flex_total
162
+ if index == len(self.children) - 1:
163
+ width += remaining - distributed
164
+ child.component.layout(Rect(x, rect.y, max(0, width), rect.height))
165
+ x += width
166
+
167
+ def render(self, canvas: Canvas) -> None:
168
+ for child in self.children:
169
+ child.component.render(canvas)
170
+
171
+ def handle(self, event: InputEvent, actions: UIActions) -> bool:
172
+ return any(child.component.handle(event, actions) for child in self.children)
173
+
174
+ @staticmethod
175
+ def _size(child: BoxChild, height: int) -> int:
176
+ if callable(child.size):
177
+ return max(0, child.size(height))
178
+ if child.size is not None:
179
+ return max(0, child.size)
180
+ return 0
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class _VisualLine:
185
+ spans: tuple[Span, ...]
186
+ metadata: object | None
187
+
188
+ @property
189
+ def text(self) -> str:
190
+ return "".join(span.text for span in self.spans)
191
+
192
+
193
+ class ScrollableText(Component):
194
+ """Scrollable rich text with selection and metadata-aware interaction."""
195
+
196
+ def __init__(
197
+ self,
198
+ lines: Callable[[int], Sequence[TextLine]],
199
+ *,
200
+ on_click: Callable[[object | None], bool] | None = None,
201
+ on_hover: Callable[[object | None], bool] | None = None,
202
+ on_selection: Callable[[str], None] | None = None,
203
+ clock: Callable[[], float] = monotonic,
204
+ double_click_seconds: float = 0.4,
205
+ ) -> None:
206
+ super().__init__()
207
+ self.lines_provider = lines
208
+ self.on_click = on_click
209
+ self.on_hover = on_hover
210
+ self.on_selection = on_selection
211
+ self.clock = clock
212
+ self.double_click_seconds = double_click_seconds
213
+ self.scroll_top = 0
214
+ self.follow_tail = True
215
+ self._visual_lines: list[_VisualLine] = []
216
+ self._selection_start: tuple[int, int] | None = None
217
+ self._selection_end: tuple[int, int] | None = None
218
+ self._dragging = False
219
+ self._mouse_down: tuple[int, int] | None = None
220
+ self._mouse_down_at = float("-inf")
221
+ self._double_click = False
222
+ self._last_click_at = float("-inf")
223
+ self._last_click_line: int | None = None
224
+
225
+ def render(self, canvas: Canvas) -> None:
226
+ self._visual_lines = _wrap_lines(self.lines_provider(max(1, self.rect.width)), max(1, self.rect.width))
227
+ maximum = max(0, len(self._visual_lines) - self.rect.height)
228
+ if self.follow_tail:
229
+ self.scroll_top = maximum
230
+ else:
231
+ self.scroll_top = min(self.scroll_top, maximum)
232
+ for local_y in range(self.rect.height):
233
+ index = self.scroll_top + local_y
234
+ if index >= len(self._visual_lines):
235
+ break
236
+ line = self._visual_lines[index]
237
+ if line.spans and line.spans[0].style.background:
238
+ canvas.fill(self.rect.x, self.rect.y + local_y, self.rect.width, 1, line.spans[0].style)
239
+ self._draw_line(canvas, line, index, local_y)
240
+
241
+ def handle(self, event: InputEvent, actions: UIActions) -> bool:
242
+ if event.type == EventType.KEY:
243
+ if event.key == "page_up":
244
+ self.scroll(-max(1, self.rect.height - 2))
245
+ return True
246
+ if event.key == "page_down":
247
+ self.scroll(max(1, self.rect.height - 2))
248
+ return True
249
+ if getattr(actions, "focused", None) is not self:
250
+ return False
251
+ if event.key == "up":
252
+ self.scroll(-1)
253
+ return True
254
+ if event.key == "down":
255
+ self.scroll(1)
256
+ return True
257
+ if event.key in ("home", "ctrl_home"):
258
+ self.scroll_top = 0
259
+ self.follow_tail = False
260
+ return True
261
+ if event.key in ("end", "ctrl_end"):
262
+ self.scroll_top = max(0, len(self._visual_lines) - self.rect.height)
263
+ self.follow_tail = True
264
+ return True
265
+ return False
266
+ if event.type != EventType.MOUSE or event.action is None:
267
+ return False
268
+ inside = self.rect.contains(event.x, event.y)
269
+ if event.action == MouseAction.SCROLL_UP and inside:
270
+ self.scroll(-3)
271
+ return True
272
+ if event.action == MouseAction.SCROLL_DOWN and inside:
273
+ self.scroll(3)
274
+ return True
275
+ point = self._point(event.x, event.y) if inside else None
276
+ metadata = self._metadata(point)
277
+ if event.action == MouseAction.MOVE:
278
+ changed = self.on_hover(metadata) if self.on_hover is not None else False
279
+ if self._dragging:
280
+ if event.y < self.rect.y:
281
+ self.scroll(-1)
282
+ elif event.y >= self.rect.y + self.rect.height:
283
+ self.scroll(1)
284
+ point = self._point_clamped(event.x, event.y)
285
+ self._selection_end = point
286
+ changed = True
287
+ return changed
288
+ if event.action == MouseAction.DOWN and inside:
289
+ actions.focus(self)
290
+ self._mouse_down = point
291
+ assert point is not None
292
+ now = self.clock()
293
+ self._double_click = (
294
+ self._last_click_line == point[0] and now - self._last_click_at <= self.double_click_seconds
295
+ )
296
+ self._mouse_down_at = now
297
+ if self._double_click:
298
+ self._select_line(point[0])
299
+ self._dragging = False
300
+ else:
301
+ if not event.shift or self._selection_start is None:
302
+ self._selection_start = point
303
+ self._selection_end = point
304
+ self._dragging = True
305
+ return True
306
+ if event.action == MouseAction.UP and (self._dragging or self._double_click):
307
+ was_double_click = self._double_click
308
+ self._double_click = False
309
+ if self._dragging:
310
+ self._dragging = False
311
+ if point is not None:
312
+ self._selection_end = point
313
+ clicked = point == self._mouse_down
314
+ if clicked and self.on_click is not None and self.on_click(metadata):
315
+ self.clear_selection()
316
+ if clicked and point is not None:
317
+ self._last_click_at = self._mouse_down_at
318
+ self._last_click_line = point[0]
319
+ else:
320
+ # Dragging is a selection gesture, not the first half of a
321
+ # double click.
322
+ self._last_click_at = float("-inf")
323
+ self._last_click_line = None
324
+ if was_double_click:
325
+ # Start a fresh click chain after a completed double click so a
326
+ # third click does not unexpectedly replace the line selection.
327
+ self._last_click_at = float("-inf")
328
+ self._last_click_line = None
329
+ self._copy_selection(actions)
330
+ return True
331
+ return False
332
+
333
+ def scroll(self, amount: int) -> None:
334
+ maximum = max(0, len(self._visual_lines) - self.rect.height)
335
+ self.scroll_top = min(max(0, self.scroll_top + amount), maximum)
336
+ self.follow_tail = self.scroll_top >= maximum
337
+
338
+ def selected_text(self) -> str:
339
+ bounds = self._selection_bounds()
340
+ if bounds is None:
341
+ return ""
342
+ (start_line, start_column), (end_line, end_column) = bounds
343
+ selected: list[str] = []
344
+ for index in range(start_line, end_line + 1):
345
+ text = self._visual_lines[index].text
346
+ left = start_column if index == start_line else 0
347
+ right = end_column if index == end_line else _display_width(text)
348
+ selected.append(_display_slice(text, left, right))
349
+ return "\n".join(selected)
350
+
351
+ def clear_selection(self) -> None:
352
+ self._selection_start = None
353
+ self._selection_end = None
354
+
355
+ def _select_line(self, line: int) -> None:
356
+ if not 0 <= line < len(self._visual_lines):
357
+ return
358
+ self._selection_start = (line, 0)
359
+ self._selection_end = (line, _display_width(self._visual_lines[line].text))
360
+
361
+ def _copy_selection(self, actions: UIActions) -> None:
362
+ selected = self.selected_text()
363
+ if not selected:
364
+ return
365
+ actions.copy(selected)
366
+ if self.on_selection is not None:
367
+ self.on_selection(selected)
368
+
369
+ def _point_clamped(self, x: int, y: int) -> tuple[int, int]:
370
+ clamped_x = min(max(x, self.rect.x), max(self.rect.x, self.rect.x + self.rect.width - 1))
371
+ clamped_y = min(max(y, self.rect.y), max(self.rect.y, self.rect.y + self.rect.height - 1))
372
+ return self._point(clamped_x, clamped_y)
373
+
374
+ def _draw_line(self, canvas: Canvas, line: _VisualLine, index: int, local_y: int) -> None:
375
+ x = self.rect.x
376
+ column = 0
377
+ bounds = self._selection_bounds()
378
+ for span in line.spans:
379
+ for character in span.text:
380
+ width = _character_width(character)
381
+ selected = False
382
+ if bounds is not None:
383
+ (start_line, start_column), (end_line, end_column) = bounds
384
+ selected = (start_line, start_column) <= (index, column) < (end_line, end_column)
385
+ style = replace(span.style, reverse=not span.style.reverse) if selected else span.style
386
+ canvas.set_cell(x + column, self.rect.y + local_y, character, style)
387
+ column += width
388
+
389
+ def _point(self, x: int, y: int) -> tuple[int, int]:
390
+ line = min(max(0, self.scroll_top + y - self.rect.y), max(0, len(self._visual_lines) - 1))
391
+ column = (
392
+ min(max(0, x - self.rect.x), _display_width(self._visual_lines[line].text)) if self._visual_lines else 0
393
+ )
394
+ return line, column
395
+
396
+ def _metadata(self, point: tuple[int, int] | None) -> object | None:
397
+ return self._visual_lines[point[0]].metadata if point is not None and self._visual_lines else None
398
+
399
+ def _selection_bounds(self) -> tuple[tuple[int, int], tuple[int, int]] | None:
400
+ if self._selection_start is None or self._selection_end is None:
401
+ return None
402
+ start, end = sorted((self._selection_start, self._selection_end))
403
+ if start == end:
404
+ return None
405
+ return start, end
406
+
407
+
408
+ class TextInput(Component):
409
+ """Multiline Readline-style editor with history and visible cursor."""
410
+
411
+ def __init__(
412
+ self,
413
+ submit: Callable[[str], bool | None],
414
+ *,
415
+ prompt: str = "› ",
416
+ max_height: int = 7,
417
+ completions: Sequence[str] = (),
418
+ history_limit: int = 200,
419
+ ) -> None:
420
+ super().__init__()
421
+ self.submit = submit
422
+ self.prompt = prompt
423
+ self.max_height = max_height
424
+ self.completions = tuple(completions)
425
+ self.history_limit = history_limit
426
+ self.text = ""
427
+ self.position = 0
428
+ self.focused = True
429
+ self._cursor_screen: tuple[int, int] | None = None
430
+ self._kill_buffer = ""
431
+ self._undo: list[tuple[str, int]] = []
432
+ self._history: list[str] = []
433
+ self._history_index: int | None = None
434
+ self._history_draft = ""
435
+ self._completion_state: tuple[str, tuple[str, ...], int] | None = None
436
+
437
+ def preferred_height(self, width: int) -> int:
438
+ lines, _ = _layout_input(self.text, self.position, width, _display_width(self.prompt))
439
+ return min(self.max_height, max(1, len(lines)))
440
+
441
+ def render(self, canvas: Canvas) -> None:
442
+ prompt_width = _display_width(self.prompt)
443
+ lines, (cursor_row, cursor_column) = _layout_input(
444
+ self.text,
445
+ self.position,
446
+ self.rect.width,
447
+ prompt_width,
448
+ )
449
+ first_visible = max(0, cursor_row - self.rect.height + 1)
450
+ for local_row, line in enumerate(lines[first_visible : first_visible + self.rect.height]):
451
+ source_row = first_visible + local_row
452
+ x = self.rect.x + (prompt_width if source_row == 0 else 0)
453
+ if source_row == 0:
454
+ canvas.draw_text(
455
+ self.rect.x,
456
+ self.rect.y + local_row,
457
+ self.prompt,
458
+ Style(foreground="#9bddad", bold=True),
459
+ )
460
+ canvas.draw_text(
461
+ x,
462
+ self.rect.y + local_row,
463
+ line,
464
+ Style(foreground="#e6e9e7"),
465
+ max_width=self.rect.width - (x - self.rect.x),
466
+ )
467
+ cursor_x = self.rect.x + (prompt_width if cursor_row == 0 else 0) + cursor_column
468
+ self._cursor_screen = (cursor_x, self.rect.y + cursor_row - first_visible)
469
+
470
+ def handle(self, event: InputEvent, actions: UIActions) -> bool:
471
+ if event.type == EventType.MOUSE and self.rect.contains(event.x, event.y) and event.action == MouseAction.DOWN:
472
+ actions.focus(self)
473
+ return True
474
+ if not self.focused:
475
+ return False
476
+ if event.type in (EventType.TEXT, EventType.PASTE):
477
+ self._insert(event.text)
478
+ return True
479
+ if event.type != EventType.KEY:
480
+ return False
481
+ if event.key not in ("tab", "backtab"):
482
+ self._completion_state = None
483
+ if event.alt:
484
+ return self._handle_alt(event.key)
485
+ match event.key:
486
+ case "left" | "ctrl_b":
487
+ self.position = max(0, self.position - 1)
488
+ case "right" | "ctrl_f":
489
+ self.position = min(len(self.text), self.position + 1)
490
+ case "home" | "ctrl_a":
491
+ self.position = self._line_start()
492
+ case "end" | "ctrl_e":
493
+ self.position = self._line_end()
494
+ case "ctrl_home":
495
+ self.position = 0
496
+ case "ctrl_end":
497
+ self.position = len(self.text)
498
+ case "ctrl_left" | "alt_left":
499
+ self.position = _previous_word(self.text, self.position)
500
+ case "ctrl_right" | "alt_right":
501
+ self.position = _next_word(self.text, self.position)
502
+ case "up":
503
+ if not self._move_vertical(-1):
504
+ self._move_history(-1)
505
+ case "down":
506
+ if not self._move_vertical(1):
507
+ self._move_history(1)
508
+ case "ctrl_p":
509
+ self._move_history(-1)
510
+ case "ctrl_n":
511
+ self._move_history(1)
512
+ case "backspace":
513
+ if self.position:
514
+ self._delete(self.position - 1, self.position)
515
+ case "delete" | "ctrl_d":
516
+ if self.position < len(self.text):
517
+ self._delete(self.position, self.position + 1)
518
+ case "ctrl_w" | "alt_backspace":
519
+ self._delete(_previous_word(self.text, self.position), self.position, kill=True)
520
+ case "ctrl_u":
521
+ self._delete(self._line_start(), self.position, kill=True)
522
+ case "ctrl_k":
523
+ self._delete(self.position, self._line_end(), kill=True)
524
+ case "ctrl_y":
525
+ if self._kill_buffer:
526
+ self._insert(self._kill_buffer)
527
+ case "ctrl_z" | "ctrl_underscore":
528
+ self._undo_once()
529
+ case "ctrl_g" | "escape":
530
+ self._leave_history()
531
+ case "ctrl_r":
532
+ self._reverse_history_search()
533
+ case "tab":
534
+ self._complete(1)
535
+ case "backtab":
536
+ self._complete(-1)
537
+ case "alt_enter":
538
+ self._insert("\n")
539
+ case "enter":
540
+ value = self.text.strip()
541
+ if value and self.submit(value) is not False:
542
+ if not self._history or self._history[-1] != value:
543
+ self._history.append(value)
544
+ del self._history[: -self.history_limit]
545
+ self.clear()
546
+ case _:
547
+ return False
548
+ return True
549
+
550
+ def cursor(self) -> tuple[int, int] | None:
551
+ return self._cursor_screen if self.focused else None
552
+
553
+ def _insert(self, value: str) -> None:
554
+ if not value:
555
+ return
556
+ # Tabs have no glyph of their own. Store their visual expansion so
557
+ # cursor movement, wrapping, selection, and rendering share one width.
558
+ value = value.expandtabs(4)
559
+ self._remember_undo()
560
+ self._leave_history()
561
+ self.text = self.text[: self.position] + value + self.text[self.position :]
562
+ self.position += len(value)
563
+
564
+ def clear(self) -> None:
565
+ """Reset the current draft and transient editing modes."""
566
+ self.text = ""
567
+ self.position = 0
568
+ self._undo.clear()
569
+ self._history_index = None
570
+ self._history_draft = ""
571
+ self._completion_state = None
572
+
573
+ def _handle_alt(self, key: str) -> bool:
574
+ match key.lower():
575
+ case "b":
576
+ self.position = _previous_word(self.text, self.position)
577
+ case "f":
578
+ self.position = _next_word(self.text, self.position)
579
+ case "d":
580
+ self._delete(self.position, _next_word(self.text, self.position), kill=True)
581
+ case "<":
582
+ self.position = 0
583
+ case ">":
584
+ self.position = len(self.text)
585
+ case _:
586
+ return False
587
+ return True
588
+
589
+ def _line_start(self) -> int:
590
+ return self.text.rfind("\n", 0, self.position) + 1
591
+
592
+ def _line_end(self) -> int:
593
+ end = self.text.find("\n", self.position)
594
+ return len(self.text) if end < 0 else end
595
+
596
+ def _move_vertical(self, direction: int) -> bool:
597
+ start = self._line_start()
598
+ column = self.position - start
599
+ if direction < 0:
600
+ if start == 0:
601
+ return False
602
+ previous_end = start - 1
603
+ previous_start = self.text.rfind("\n", 0, previous_end) + 1
604
+ self.position = min(previous_start + column, previous_end)
605
+ return True
606
+ end = self._line_end()
607
+ if end == len(self.text):
608
+ return False
609
+ next_start = end + 1
610
+ next_end = self.text.find("\n", next_start)
611
+ if next_end < 0:
612
+ next_end = len(self.text)
613
+ self.position = min(next_start + column, next_end)
614
+ return True
615
+
616
+ def _delete(self, start: int, end: int, *, kill: bool = False) -> None:
617
+ start, end = max(0, start), min(len(self.text), end)
618
+ if start >= end:
619
+ return
620
+ self._remember_undo()
621
+ removed = self.text[start:end]
622
+ if kill:
623
+ self._kill_buffer = removed
624
+ self.text = self.text[:start] + self.text[end:]
625
+ self.position = start
626
+ self._leave_history()
627
+
628
+ def _remember_undo(self) -> None:
629
+ state = (self.text, self.position)
630
+ if not self._undo or self._undo[-1] != state:
631
+ self._undo.append(state)
632
+ del self._undo[:-100]
633
+
634
+ def _undo_once(self) -> None:
635
+ if self._undo:
636
+ self.text, self.position = self._undo.pop()
637
+ self._leave_history()
638
+
639
+ def _move_history(self, direction: int) -> None:
640
+ if not self._history:
641
+ return
642
+ if self._history_index is None:
643
+ if direction > 0:
644
+ return
645
+ self._history_draft = self.text
646
+ self._history_index = len(self._history) - 1
647
+ else:
648
+ next_index = self._history_index + direction
649
+ if next_index >= len(self._history):
650
+ self.text = self._history_draft
651
+ self.position = len(self.text)
652
+ self._history_index = None
653
+ return
654
+ self._history_index = max(0, next_index)
655
+ self.text = self._history[self._history_index]
656
+ self.position = len(self.text)
657
+
658
+ def _leave_history(self) -> None:
659
+ self._history_index = None
660
+
661
+ def _reverse_history_search(self) -> None:
662
+ if not self._history:
663
+ return
664
+ query = self.text if self._history_index is None else self._history_draft
665
+ start = len(self._history) if self._history_index is None else self._history_index
666
+ match = next((index for index in range(start - 1, -1, -1) if query in self._history[index]), None)
667
+ if match is None:
668
+ return
669
+ if self._history_index is None:
670
+ self._history_draft = query
671
+ self._history_index = match
672
+ self.text = self._history[match]
673
+ self.position = len(self.text)
674
+
675
+ def _complete(self, direction: int) -> None:
676
+ state = self._completion_state
677
+ if state is None:
678
+ start = self.text.rfind("\n", 0, self.position) + 1
679
+ token = self.text[start : self.position]
680
+ if not token.startswith("/") or any(character.isspace() for character in token):
681
+ return
682
+ matches = tuple(value for value in self.completions if value.startswith(token))
683
+ if not matches:
684
+ return
685
+ state = (token, matches, -1 if direction > 0 else 0)
686
+ original, matches, index = state
687
+ index = (index + direction) % len(matches)
688
+ start = self.text.rfind("\n", 0, self.position) + 1
689
+ current = self.text[start : self.position]
690
+ self._remember_undo()
691
+ self.text = self.text[:start] + matches[index] + self.text[self.position :]
692
+ self.position += len(matches[index]) - len(current)
693
+ self._completion_state = (original, matches, index)
694
+
695
+
696
+ def _previous_word(value: str, position: int) -> int:
697
+ index = position
698
+ while index and value[index - 1].isspace():
699
+ index -= 1
700
+ if index and _word_character(value[index - 1]):
701
+ while index and _word_character(value[index - 1]):
702
+ index -= 1
703
+ else:
704
+ while index and not value[index - 1].isspace() and not _word_character(value[index - 1]):
705
+ index -= 1
706
+ return index
707
+
708
+
709
+ def _next_word(value: str, position: int) -> int:
710
+ index = position
711
+ while index < len(value) and value[index].isspace():
712
+ index += 1
713
+ if index < len(value) and _word_character(value[index]):
714
+ while index < len(value) and _word_character(value[index]):
715
+ index += 1
716
+ else:
717
+ while index < len(value) and not value[index].isspace() and not _word_character(value[index]):
718
+ index += 1
719
+ return index
720
+
721
+
722
+ def _word_character(character: str) -> bool:
723
+ return character.isalnum() or character == "_"
724
+
725
+
726
+ def _wrap_lines(lines: Sequence[TextLine], width: int) -> list[_VisualLine]:
727
+ result: list[_VisualLine] = []
728
+ for line in lines:
729
+ spans = _expand_tabs(line.spans)
730
+ current: list[Span] = []
731
+ used = 0
732
+ for span in spans:
733
+ chunk = ""
734
+ for character in span.text:
735
+ char_width = _character_width(character)
736
+ if used and used + char_width > width:
737
+ if chunk:
738
+ current.append(Span(chunk, span.style))
739
+ result.append(_VisualLine(tuple(current), line.metadata))
740
+ current, chunk, used = [], "", 0
741
+ chunk += character
742
+ used += char_width
743
+ if chunk:
744
+ current.append(Span(chunk, span.style))
745
+ result.append(_VisualLine(tuple(current), line.metadata))
746
+ return result or [_VisualLine((), None)]
747
+
748
+
749
+ def _expand_tabs(spans: tuple[Span, ...], *, tab_size: int = 4) -> tuple[Span, ...]:
750
+ """Expand tabs across span boundaries while preserving their styles."""
751
+ expanded: list[Span] = []
752
+ column = 0
753
+ for span in spans:
754
+ chunk = ""
755
+ for character in span.text:
756
+ if character == "\t":
757
+ spaces = tab_size - (column % tab_size)
758
+ chunk += " " * spaces
759
+ column += spaces
760
+ else:
761
+ chunk += character
762
+ column += _character_width(character)
763
+ if chunk:
764
+ expanded.append(Span(chunk, span.style))
765
+ return tuple(expanded)
766
+
767
+
768
+ def _display_width(value: str) -> int:
769
+ return sum(_character_width(character) for character in value)
770
+
771
+
772
+ def _display_slice(value: str, start: int, end: int) -> str:
773
+ output = []
774
+ column = 0
775
+ for character in value:
776
+ width = _character_width(character)
777
+ if column >= end:
778
+ break
779
+ if column + width > start:
780
+ output.append(character)
781
+ column += width
782
+ return "".join(output)
783
+
784
+
785
+ def _layout_input(text: str, position: int, width: int, prompt_width: int) -> tuple[list[str], tuple[int, int]]:
786
+ """Wrap editable text while preserving a source-index cursor."""
787
+ width = max(1, width)
788
+ rows = [""]
789
+ row = 0
790
+ column = 0
791
+ cursor = (0, 0)
792
+ for index, character in enumerate(text):
793
+ if index == position:
794
+ available = max(1, width - (prompt_width if row == 0 else 0))
795
+ cursor = (row + 1, 0) if column >= available else (row, column)
796
+ if character == "\n":
797
+ rows.append("")
798
+ row += 1
799
+ column = 0
800
+ continue
801
+ character_width = _character_width(character)
802
+ available = max(1, width - (prompt_width if row == 0 else 0))
803
+ if column and column + character_width > available:
804
+ rows.append("")
805
+ row += 1
806
+ column = 0
807
+ rows[row] += character
808
+ column += character_width
809
+ if position == len(text):
810
+ available = max(1, width - (prompt_width if row == 0 else 0))
811
+ if column >= available:
812
+ rows.append("")
813
+ cursor = (row + 1, 0)
814
+ else:
815
+ cursor = (row, column)
816
+ return rows, cursor
817
+
818
+
819
+ def _character_width(character: str) -> int:
820
+ width = wcwidth(character)
821
+ return width if width >= 0 else 1