zettcode-tui 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""A virtualized scrollable window over a line source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from time import monotonic
|
|
10
|
+
|
|
11
|
+
from ..core.events import KeyEvent, MouseAction, MouseEvent
|
|
12
|
+
from ..core.geometry import Constraints, Size
|
|
13
|
+
from ..core.host import Host
|
|
14
|
+
from ..core.widget import Widget
|
|
15
|
+
from ..render import Canvas, TextLine
|
|
16
|
+
from ..render.text import display_width, slice_columns
|
|
17
|
+
|
|
18
|
+
#: Rows one wheel event scrolls. One event is one row however fast they come, which is how Codex does it:
|
|
19
|
+
#: a mouse wheel's notch is one row, a trackpad that sends sixty events a second scrolls sixty rows a
|
|
20
|
+
#: second, and nothing grows with how long the wheel has been turning. The reader who wants it faster
|
|
21
|
+
#: says so (``ScrollView(wheel_speed=...)``, ``transcript.mouse_scroll_speed`` in the configuration)
|
|
22
|
+
#: instead of the view guessing from how hard they flicked. An earlier version did guess — a turn that
|
|
23
|
+
#: went on gained a row a notch, to ten — and a trackpad's momentum then threw the view across several
|
|
24
|
+
#: pages in one flick.
|
|
25
|
+
WHEEL_SPEED = 1.0
|
|
26
|
+
|
|
27
|
+
#: The most a single frame may move the view by the wheel, as a share of the window: half. Events that
|
|
28
|
+
#: pile up (a terminal that was busy, a flick delivered in one burst) are applied together and drawn
|
|
29
|
+
#: once, so without a bound the rows between where the view was and where it ends up are never drawn at
|
|
30
|
+
#: all, which reads as content that was skipped. Half a window always leaves half of what was on screen
|
|
31
|
+
#: still there to follow.
|
|
32
|
+
WHEEL_FRAME_SHARE = 0.5
|
|
33
|
+
|
|
34
|
+
#: A selection after clamping: its first cell and its last cell, each a row and a column.
|
|
35
|
+
Selection = tuple[tuple[int, int], tuple[int, int]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LineSource(ABC):
|
|
39
|
+
"""Supplies already-wrapped lines without materializing the whole list.
|
|
40
|
+
|
|
41
|
+
``count`` must be cheap, because it runs on every frame, and ``line`` is
|
|
42
|
+
only ever called for rows that are actually visible. Subclass this and
|
|
43
|
+
answer both; a class that merely looks compatible does not count.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def count(self, width: int) -> int:
|
|
48
|
+
"""Return the total number of lines at ``width``; this runs every frame."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
52
|
+
"""Return one line; only indexes that are actually visible are requested."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StaticLines(LineSource):
|
|
56
|
+
"""A LineSource over a fixed sequence of lines."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, lines: Sequence[TextLine] = ()) -> None:
|
|
59
|
+
"""Freeze an already-wrapped sequence of lines."""
|
|
60
|
+
self.lines = tuple(lines)
|
|
61
|
+
|
|
62
|
+
def count(self, width: int) -> int:
|
|
63
|
+
"""Return the number of stored lines; the width is ignored."""
|
|
64
|
+
return len(self.lines)
|
|
65
|
+
|
|
66
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
67
|
+
"""Return one stored line; the width is already baked in."""
|
|
68
|
+
return self.lines[index]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ScrollView(Widget):
|
|
72
|
+
"""Show a window of a LineSource and keep scrolling anchored.
|
|
73
|
+
|
|
74
|
+
``top`` is an absolute line index rather than a distance from the bottom,
|
|
75
|
+
so appending content never moves the view while the reader is scrolled up.
|
|
76
|
+
Call :meth:`adjust_for_insertion` when lines are inserted above the window
|
|
77
|
+
to keep the same content on screen.
|
|
78
|
+
|
|
79
|
+
Shape::
|
|
80
|
+
|
|
81
|
+
line 01 <- row 0 of the window (top = 0 here)
|
|
82
|
+
line 02
|
|
83
|
+
line 03 <- only rect.height lines are asked for
|
|
84
|
+
line 04
|
|
85
|
+
------
|
|
86
|
+
^ the source is never materialised: count(width) then line(i, width)
|
|
87
|
+
|
|
88
|
+
``measure`` claims nothing, because the window size comes from the slot it
|
|
89
|
+
is placed in, not from its contents. With ``selectable`` a drag inverts the
|
|
90
|
+
covered cells and copies them on release.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
source: LineSource,
|
|
96
|
+
*,
|
|
97
|
+
follow_tail: bool = True,
|
|
98
|
+
focusable: bool = False,
|
|
99
|
+
selectable: bool = False,
|
|
100
|
+
clock: Callable[[], float] = monotonic,
|
|
101
|
+
double_click_seconds: float = 0.4,
|
|
102
|
+
wheel_speed: float = WHEEL_SPEED,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Configure the window, the tail behavior, and the selection state.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
source: Supplies already-wrapped lines; only visible indexes are read.
|
|
108
|
+
follow_tail: Stick to the newest line until the reader scrolls up.
|
|
109
|
+
focusable: Let Tab traversal land on the view so its keys work.
|
|
110
|
+
selectable: Enable mouse selection and copying.
|
|
111
|
+
clock: Time source used to detect double clicks.
|
|
112
|
+
double_click_seconds: Window in which two clicks on one line count as
|
|
113
|
+
a double click, which selects the whole line.
|
|
114
|
+
wheel_speed: Rows one wheel event scrolls; a fraction is carried to the next event, so
|
|
115
|
+
``0.5`` is a row every second event. Not positive or not finite is the default.
|
|
116
|
+
"""
|
|
117
|
+
super().__init__()
|
|
118
|
+
self.source = source
|
|
119
|
+
self.follow_tail = follow_tail
|
|
120
|
+
self.top = 0
|
|
121
|
+
self._focusable = focusable
|
|
122
|
+
self.selectable = selectable
|
|
123
|
+
self.clock = clock
|
|
124
|
+
self.double_click_seconds = double_click_seconds
|
|
125
|
+
self._selection_start: tuple[int, int] | None = None
|
|
126
|
+
self._selection_end: tuple[int, int] | None = None
|
|
127
|
+
self._dragging = False
|
|
128
|
+
self._mouse_down: tuple[int, int] | None = None
|
|
129
|
+
self._mouse_down_at = float("-inf")
|
|
130
|
+
self._double_click = False
|
|
131
|
+
self._last_click_at = float("-inf")
|
|
132
|
+
self._last_click_line: int | None = None
|
|
133
|
+
self.wheel_speed = wheel_speed if math.isfinite(wheel_speed) and wheel_speed > 0 else WHEEL_SPEED
|
|
134
|
+
self._wheel_owed = 0.0
|
|
135
|
+
self._frame_start: int | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def focusable(self) -> bool:
|
|
139
|
+
"""Report whether Tab traversal may land on this view."""
|
|
140
|
+
return self._focusable
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def selects_text(self) -> bool:
|
|
144
|
+
"""Keep the drag: this view selects its own lines rather than the frame."""
|
|
145
|
+
return self.selectable
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def line_width(self) -> int:
|
|
149
|
+
"""Return the width lines are wrapped to, never below one column."""
|
|
150
|
+
return max(1, self.rect.width)
|
|
151
|
+
|
|
152
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
153
|
+
"""Claim no preferred size; the parent decides the viewport."""
|
|
154
|
+
return constraints.constrain(Size(0, 0))
|
|
155
|
+
|
|
156
|
+
def render(self, canvas: Canvas) -> None:
|
|
157
|
+
"""Clamp the window first, then paint the visible rows and selection."""
|
|
158
|
+
count = self.line_count()
|
|
159
|
+
wanted = count - self.rect.height if self.follow_tail else self.top
|
|
160
|
+
self.top = self._clamp(wanted, count)
|
|
161
|
+
selection = self._clamped_selection(count)
|
|
162
|
+
if selection is None and self.selection_bounds() is not None and not (self._dragging or self._double_click):
|
|
163
|
+
self.clear_selection()
|
|
164
|
+
self._frame_start = None # the next frame measures how far the wheel has moved from here
|
|
165
|
+
for row in range(self.rect.height):
|
|
166
|
+
index = self.top + row
|
|
167
|
+
if index >= count:
|
|
168
|
+
break
|
|
169
|
+
line = self.source.line(index, self.line_width)
|
|
170
|
+
canvas.draw_spans(self.rect.x, self.rect.y + row, line.spans, max_width=self.rect.width)
|
|
171
|
+
self._paint_selection(canvas, row, index, line, selection)
|
|
172
|
+
|
|
173
|
+
def handle(self, event, host: Host) -> bool:
|
|
174
|
+
"""Scroll with the wheel or keys, running selection when enabled."""
|
|
175
|
+
if isinstance(event, MouseEvent) and self.selectable:
|
|
176
|
+
return self._handle_pointer(event, host)
|
|
177
|
+
if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
|
|
178
|
+
if event.action is MouseAction.SCROLL_UP:
|
|
179
|
+
self._wheel(-1)
|
|
180
|
+
return True
|
|
181
|
+
if event.action is MouseAction.SCROLL_DOWN:
|
|
182
|
+
self._wheel(1)
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
185
|
+
if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
|
|
186
|
+
return False
|
|
187
|
+
page = max(1, self.rect.height - 1)
|
|
188
|
+
match event.key:
|
|
189
|
+
case "up":
|
|
190
|
+
self.scroll_by(-1)
|
|
191
|
+
case "down":
|
|
192
|
+
self.scroll_by(1)
|
|
193
|
+
case "page_up":
|
|
194
|
+
self.scroll_by(-page)
|
|
195
|
+
case "page_down":
|
|
196
|
+
self.scroll_by(page)
|
|
197
|
+
case "home" | "ctrl_home":
|
|
198
|
+
self.scroll_to(0)
|
|
199
|
+
case "end" | "ctrl_end":
|
|
200
|
+
self.scroll_end()
|
|
201
|
+
case _:
|
|
202
|
+
return False
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
def selection_bounds(self) -> Selection | None:
|
|
206
|
+
"""Return the sorted selection corners, or None when nothing is selected."""
|
|
207
|
+
if self._selection_start is None or self._selection_end is None:
|
|
208
|
+
return None
|
|
209
|
+
start, end = sorted((self._selection_start, self._selection_end))
|
|
210
|
+
return None if start == end else (start, end)
|
|
211
|
+
|
|
212
|
+
def has_selection(self) -> bool:
|
|
213
|
+
"""Return whether a selection still covers at least one cell in the source.
|
|
214
|
+
|
|
215
|
+
This asks the source for its current count, so a virtualized source may
|
|
216
|
+
synchronize itself; it does not change the selection.
|
|
217
|
+
"""
|
|
218
|
+
return self._clamped_selection() is not None
|
|
219
|
+
|
|
220
|
+
def _clamped_selection(self, count: int | None = None) -> Selection | None:
|
|
221
|
+
"""Return the selection clamped to the current source, or None when nothing remains.
|
|
222
|
+
|
|
223
|
+
A transcript can shrink while a selection is held. The old line numbers
|
|
224
|
+
then name rows that no longer exist; this is the one place that maps them
|
|
225
|
+
onto the current source for painting, copying and the Escape predicate.
|
|
226
|
+
Leading and trailing rows that cover no cell are dropped, so a selection
|
|
227
|
+
does not copy blank lines or answer Escape when nothing is visible. Rows
|
|
228
|
+
are matched by index only: a rewrap changes what a row holds, and the
|
|
229
|
+
selection follows the row, not the text that was under the pointer.
|
|
230
|
+
"""
|
|
231
|
+
bounds = self.selection_bounds()
|
|
232
|
+
if bounds is None:
|
|
233
|
+
return None
|
|
234
|
+
count = self.line_count() if count is None else count
|
|
235
|
+
if count <= 0:
|
|
236
|
+
return None
|
|
237
|
+
(first, first_column), (last, last_column) = bounds
|
|
238
|
+
if first >= count:
|
|
239
|
+
return None
|
|
240
|
+
removed_end = last >= count
|
|
241
|
+
last = min(last, count - 1)
|
|
242
|
+
|
|
243
|
+
def row_span(index: int) -> tuple[int, int] | None:
|
|
244
|
+
text = self.source.line(index, self.line_width).text
|
|
245
|
+
width = display_width(text)
|
|
246
|
+
left = min(max(0, first_column), width) if index == first else 0
|
|
247
|
+
if index == last and removed_end:
|
|
248
|
+
right = width
|
|
249
|
+
elif index == last:
|
|
250
|
+
right = min(max(0, last_column), width)
|
|
251
|
+
else:
|
|
252
|
+
right = width
|
|
253
|
+
return None if right <= left else (left, right)
|
|
254
|
+
|
|
255
|
+
start: tuple[int, int] | None = None
|
|
256
|
+
for index in range(first, last + 1):
|
|
257
|
+
span = row_span(index)
|
|
258
|
+
if span is not None:
|
|
259
|
+
start = (index, span[0])
|
|
260
|
+
break
|
|
261
|
+
if start is None:
|
|
262
|
+
return None
|
|
263
|
+
end: tuple[int, int] | None = None
|
|
264
|
+
for index in range(last, start[0] - 1, -1):
|
|
265
|
+
span = row_span(index)
|
|
266
|
+
if span is not None:
|
|
267
|
+
end = (index, span[1])
|
|
268
|
+
break
|
|
269
|
+
return None if end is None else (start, end)
|
|
270
|
+
|
|
271
|
+
def selected_text(self) -> str:
|
|
272
|
+
"""Return the text the current selection covers, clamped to the source."""
|
|
273
|
+
bounds = self._clamped_selection()
|
|
274
|
+
if bounds is None:
|
|
275
|
+
return ""
|
|
276
|
+
(first, first_column), (last, last_column) = bounds
|
|
277
|
+
lines: list[str] = []
|
|
278
|
+
for index in range(first, last + 1):
|
|
279
|
+
text = self.source.line(index, self.line_width).text
|
|
280
|
+
left = first_column if index == first else 0
|
|
281
|
+
right = last_column if index == last else display_width(text)
|
|
282
|
+
lines.append(slice_columns(text, left, right))
|
|
283
|
+
return "\n".join(lines)
|
|
284
|
+
|
|
285
|
+
def clear_selection(self) -> None:
|
|
286
|
+
"""Drop both selection corners and end any drag that belonged to them."""
|
|
287
|
+
self._selection_start = None
|
|
288
|
+
self._selection_end = None
|
|
289
|
+
self._dragging = False
|
|
290
|
+
self._double_click = False
|
|
291
|
+
self._mouse_down = None
|
|
292
|
+
self._mouse_down_at = float("-inf")
|
|
293
|
+
|
|
294
|
+
def _handle_pointer(self, event: MouseEvent, host: Host) -> bool:
|
|
295
|
+
"""Implement click, drag, and double-click selection for the mouse."""
|
|
296
|
+
inside = self.rect.contains(event.x, event.y)
|
|
297
|
+
if event.action is MouseAction.SCROLL_UP and inside:
|
|
298
|
+
self._wheel(-1)
|
|
299
|
+
return True
|
|
300
|
+
if event.action is MouseAction.SCROLL_DOWN and inside:
|
|
301
|
+
self._wheel(1)
|
|
302
|
+
return True
|
|
303
|
+
if event.action is MouseAction.DOWN and inside:
|
|
304
|
+
point = self._point(event.x, event.y)
|
|
305
|
+
now = self.clock()
|
|
306
|
+
self._double_click = (
|
|
307
|
+
self._last_click_line == point[0] and now - self._last_click_at <= self.double_click_seconds
|
|
308
|
+
)
|
|
309
|
+
self._mouse_down = point
|
|
310
|
+
self._mouse_down_at = now
|
|
311
|
+
if self._double_click:
|
|
312
|
+
self._select_line(point[0])
|
|
313
|
+
self._dragging = False
|
|
314
|
+
else:
|
|
315
|
+
if not event.shift or self._selection_start is None:
|
|
316
|
+
self._selection_start = point
|
|
317
|
+
self._selection_end = point
|
|
318
|
+
self._dragging = True
|
|
319
|
+
return True
|
|
320
|
+
if event.action is MouseAction.MOVE and (self._dragging or self._double_click):
|
|
321
|
+
# A double click that keeps moving is a drag: the second press
|
|
322
|
+
# selected the whole line, and carrying on from there extends it
|
|
323
|
+
# instead of leaving the reader stuck with that one row.
|
|
324
|
+
self._double_click = False
|
|
325
|
+
self._dragging = True
|
|
326
|
+
if event.y < self.rect.y:
|
|
327
|
+
self.scroll_by(-1)
|
|
328
|
+
elif event.y >= self.rect.y + self.rect.height:
|
|
329
|
+
self.scroll_by(1)
|
|
330
|
+
self._selection_end = self._point_clamped(event.x, event.y)
|
|
331
|
+
return True
|
|
332
|
+
if event.action is MouseAction.UP and (self._dragging or self._double_click):
|
|
333
|
+
finish_double_click = self._double_click
|
|
334
|
+
self._double_click = False
|
|
335
|
+
if self._dragging:
|
|
336
|
+
self._dragging = False
|
|
337
|
+
if inside:
|
|
338
|
+
self._selection_end = self._point(event.x, event.y)
|
|
339
|
+
clicked = inside and self._point(event.x, event.y) == self._mouse_down
|
|
340
|
+
if clicked:
|
|
341
|
+
self._last_click_at = self._mouse_down_at
|
|
342
|
+
self._last_click_line = self._mouse_down[0] if self._mouse_down else None
|
|
343
|
+
else:
|
|
344
|
+
self._last_click_at = float("-inf")
|
|
345
|
+
self._last_click_line = None
|
|
346
|
+
if finish_double_click:
|
|
347
|
+
self._last_click_at = float("-inf")
|
|
348
|
+
self._last_click_line = None
|
|
349
|
+
self._copy_selection(host)
|
|
350
|
+
return True
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
def _paint_selection(
|
|
354
|
+
self,
|
|
355
|
+
canvas: Canvas,
|
|
356
|
+
row: int,
|
|
357
|
+
index: int,
|
|
358
|
+
line: TextLine,
|
|
359
|
+
bounds: Selection | None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Invert the cells the selection covers on one visible row."""
|
|
362
|
+
if bounds is None:
|
|
363
|
+
return
|
|
364
|
+
(first, first_column), (last, last_column) = bounds
|
|
365
|
+
if not first <= index <= last:
|
|
366
|
+
return
|
|
367
|
+
start = first_column if index == first else 0
|
|
368
|
+
end = last_column if index == last else display_width(line.text)
|
|
369
|
+
if end > start:
|
|
370
|
+
canvas.restyle(self.rect.x + start, self.rect.y + row, end - start, _invert)
|
|
371
|
+
|
|
372
|
+
def _copy_selection(self, host: Host) -> None:
|
|
373
|
+
"""Hand the selected text to the host clipboard."""
|
|
374
|
+
selected = self.selected_text()
|
|
375
|
+
if selected:
|
|
376
|
+
host.copy(selected)
|
|
377
|
+
|
|
378
|
+
def _select_line(self, index: int) -> None:
|
|
379
|
+
"""Select a whole line, clamping the index to the buffer."""
|
|
380
|
+
if self.line_count() == 0:
|
|
381
|
+
return
|
|
382
|
+
index = min(max(0, index), self.line_count() - 1)
|
|
383
|
+
text = self.source.line(index, self.line_width).text
|
|
384
|
+
self._selection_start = (index, 0)
|
|
385
|
+
self._selection_end = (index, max(1, display_width(text)))
|
|
386
|
+
|
|
387
|
+
def _point(self, x: int, y: int) -> tuple[int, int]:
|
|
388
|
+
"""Translate a screen cell into a clamped (line, column) point."""
|
|
389
|
+
count = max(0, self.line_count())
|
|
390
|
+
line = min(max(0, self.top + y - self.rect.y), max(0, count - 1))
|
|
391
|
+
text = self.source.line(line, self.line_width).text if count else ""
|
|
392
|
+
column = min(max(0, x - self.rect.x), display_width(text))
|
|
393
|
+
return line, column
|
|
394
|
+
|
|
395
|
+
def _point_clamped(self, x: int, y: int) -> tuple[int, int]:
|
|
396
|
+
"""Translate a point outside the viewport, as during a drag."""
|
|
397
|
+
clamped_x = min(max(x, self.rect.x), max(self.rect.x, self.rect.x + self.rect.width - 1))
|
|
398
|
+
clamped_y = min(max(y, self.rect.y), max(self.rect.y, self.rect.y + self.rect.height - 1))
|
|
399
|
+
return self._point(clamped_x, clamped_y)
|
|
400
|
+
|
|
401
|
+
def line_count(self) -> int:
|
|
402
|
+
"""Return how many lines the source reports at the current width."""
|
|
403
|
+
return self.source.count(self.line_width)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def max_top(self) -> int:
|
|
407
|
+
"""Return the largest ``top`` that still fills the viewport."""
|
|
408
|
+
return max(0, self.line_count() - self.rect.height)
|
|
409
|
+
|
|
410
|
+
def visible_range(self) -> tuple[int, int]:
|
|
411
|
+
"""Return the half-open range of line indexes currently on screen."""
|
|
412
|
+
count = self.line_count()
|
|
413
|
+
return self.top, min(count, self.top + self.rect.height)
|
|
414
|
+
|
|
415
|
+
def scroll_to(self, index: int) -> None:
|
|
416
|
+
"""Jump so that index is the first visible line.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
index: Absolute line index, clamped into the scrollable range. The
|
|
420
|
+
tail is followed again only when the window reaches the end.
|
|
421
|
+
"""
|
|
422
|
+
count = self.line_count()
|
|
423
|
+
maximum = max(0, count - self.rect.height)
|
|
424
|
+
self.top = min(max(0, index), maximum)
|
|
425
|
+
self.follow_tail = maximum == 0 or self.top >= maximum
|
|
426
|
+
|
|
427
|
+
def _wheel(self, direction: int) -> None:
|
|
428
|
+
"""Scroll one wheel event: :attr:`wheel_speed` rows, and never far enough in a frame to skip content.
|
|
429
|
+
|
|
430
|
+
The fraction of a row a slow speed leaves over is carried to the next event, and dropped when the
|
|
431
|
+
wheel turns the other way, so a reversal starts from a whole row.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
direction: ``-1`` for up, ``1`` for down.
|
|
435
|
+
"""
|
|
436
|
+
if self._wheel_owed * direction < 0:
|
|
437
|
+
self._wheel_owed = 0.0
|
|
438
|
+
self._wheel_owed += direction * self.wheel_speed
|
|
439
|
+
rows = int(self._wheel_owed)
|
|
440
|
+
# A speed such as 0.1 reaches a whole row after repeated addition only by a hair; round the hair off.
|
|
441
|
+
if rows == 0 and abs(round(self._wheel_owed) - self._wheel_owed) < 1e-9 and round(self._wheel_owed) != 0:
|
|
442
|
+
rows = int(round(self._wheel_owed))
|
|
443
|
+
self._wheel_owed -= rows
|
|
444
|
+
if rows == 0:
|
|
445
|
+
return
|
|
446
|
+
if self._frame_start is None:
|
|
447
|
+
self._frame_start = self.top
|
|
448
|
+
reach = max(1, int(self.rect.height * WHEEL_FRAME_SHARE))
|
|
449
|
+
target = min(max(self.top + rows, self._frame_start - reach), self._frame_start + reach)
|
|
450
|
+
self.scroll_to(target)
|
|
451
|
+
|
|
452
|
+
def scroll_by(self, amount: int) -> None:
|
|
453
|
+
"""Move the window by a relative number of lines."""
|
|
454
|
+
self.scroll_to(self.top + amount)
|
|
455
|
+
|
|
456
|
+
def scroll_end(self) -> None:
|
|
457
|
+
"""Follow the tail again, so appended lines stay visible."""
|
|
458
|
+
self.follow_tail = True
|
|
459
|
+
|
|
460
|
+
def adjust_for_insertion(self, index: int, count: int = 1) -> None:
|
|
461
|
+
"""Keep the same content visible when lines are inserted above.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
index: Line index where the inserted lines begin.
|
|
465
|
+
count: How many lines were inserted there.
|
|
466
|
+
"""
|
|
467
|
+
if index < self.top:
|
|
468
|
+
self.top += count
|
|
469
|
+
|
|
470
|
+
def _clamp(self, top: int, count: int) -> int:
|
|
471
|
+
"""Keep ``top`` inside the scrollable range."""
|
|
472
|
+
return min(max(0, top), max(0, count - self.rect.height))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _invert(style):
|
|
476
|
+
"""Flip reverse video, which is how a selection marks a cell."""
|
|
477
|
+
return replace(style, reverse=not style.reverse)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Constraint arithmetic shared by the layout containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
TrackSize: TypeAlias = int | Callable[[int], int] | None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Track:
|
|
14
|
+
"""One row or column in a box layout.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
size: Fixed cells on the main axis, a callable receiving the box's
|
|
18
|
+
extent on the *cross* axis, or ``None`` to contribute nothing to the
|
|
19
|
+
fixed budget.
|
|
20
|
+
flex: Share of the leftover space; zero never grows.
|
|
21
|
+
minimum: Floor applied to the final extent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
size: TrackSize = None
|
|
25
|
+
flex: int = 0
|
|
26
|
+
minimum: int = 0
|
|
27
|
+
|
|
28
|
+
def fixed(self, available: int, cross: int) -> int:
|
|
29
|
+
"""Return the extent this track claims before flex is shared out.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
available: Cells the box has on the main axis. Kept for the callers
|
|
33
|
+
that reason in main-axis terms; a callable ``size`` does not see
|
|
34
|
+
it.
|
|
35
|
+
cross: Cells the box has on the other axis, which is what a callable
|
|
36
|
+
``size`` receives: a child's natural main-axis size usually
|
|
37
|
+
depends on the space it gets across (a paragraph's height
|
|
38
|
+
depends on its width).
|
|
39
|
+
"""
|
|
40
|
+
if callable(self.size):
|
|
41
|
+
return max(self.minimum, self.size(cross))
|
|
42
|
+
if self.size is None:
|
|
43
|
+
return self.minimum
|
|
44
|
+
return max(self.minimum, self.size)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_tracks(available: int, tracks: Sequence[Track], *, cross: int | None = None) -> list[int]:
|
|
48
|
+
"""Return the extent of every track, honoring fixed, minimum, and flex.
|
|
49
|
+
|
|
50
|
+
Fixed tracks keep their size and flex tracks share whatever is left. When
|
|
51
|
+
the fixed sizes alone overflow the space, every track is scaled down
|
|
52
|
+
proportionally so a box never paints outside its own rectangle.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
available: Cells the box has on the main axis.
|
|
56
|
+
tracks: Per-slot sizing requests, in layout order.
|
|
57
|
+
cross: Cells the box has on the other axis, handed to a callable
|
|
58
|
+
``size``. A caller that only uses fixed numbers can leave it out, in
|
|
59
|
+
which case a callable would receive ``available`` instead.
|
|
60
|
+
"""
|
|
61
|
+
available = max(0, available)
|
|
62
|
+
if not tracks:
|
|
63
|
+
return []
|
|
64
|
+
extent = available if cross is None else cross
|
|
65
|
+
fixed = [track.fixed(available, extent) for track in tracks]
|
|
66
|
+
if sum(fixed) > available:
|
|
67
|
+
return _shrink(fixed, available)
|
|
68
|
+
remaining = available - sum(fixed)
|
|
69
|
+
flex_total = sum(max(0, track.flex) for track in tracks)
|
|
70
|
+
if remaining and flex_total:
|
|
71
|
+
extra = [remaining * max(0, track.flex) // flex_total for track in tracks]
|
|
72
|
+
leftover = remaining - sum(extra)
|
|
73
|
+
for index, track in enumerate(tracks):
|
|
74
|
+
if leftover <= 0:
|
|
75
|
+
break
|
|
76
|
+
if track.flex > 0:
|
|
77
|
+
extra[index] += 1
|
|
78
|
+
leftover -= 1
|
|
79
|
+
fixed = [size + bonus for size, bonus in zip(fixed, extra, strict=True)]
|
|
80
|
+
return fixed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _shrink(sizes: list[int], available: int) -> list[int]:
|
|
84
|
+
"""Scale a budget down to fit, handing the rounding remainder forward."""
|
|
85
|
+
total = sum(sizes)
|
|
86
|
+
if total <= available or total == 0:
|
|
87
|
+
return sizes
|
|
88
|
+
scaled = [size * available // total for size in sizes]
|
|
89
|
+
leftover = available - sum(scaled)
|
|
90
|
+
index = 0
|
|
91
|
+
while leftover > 0 and scaled:
|
|
92
|
+
scaled[index % len(scaled)] += 1
|
|
93
|
+
leftover -= 1
|
|
94
|
+
index += 1
|
|
95
|
+
return scaled
|