zettcode-tui 0.4.0__py3-none-any.whl

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