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.
- zettcode/__init__.py +6 -0
- zettcode/cli.py +78 -0
- zettcode/config.py +72 -0
- zettcode/runtime.py +121 -0
- zettcode/tui.py +924 -0
- zettcode/tui_framework/__init__.py +29 -0
- zettcode/tui_framework/application.py +91 -0
- zettcode/tui_framework/components.py +821 -0
- zettcode/tui_framework/events.py +37 -0
- zettcode/tui_framework/input.py +163 -0
- zettcode/tui_framework/screen.py +214 -0
- zettcode/tui_framework/terminal.py +118 -0
- zettcode-0.1.0.dist-info/METADATA +109 -0
- zettcode-0.1.0.dist-info/RECORD +17 -0
- zettcode-0.1.0.dist-info/WHEEL +4 -0
- zettcode-0.1.0.dist-info/entry_points.txt +2 -0
- zettcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|