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,147 @@
|
|
|
1
|
+
"""Asynchronous bridge from the TTY file descriptor into decoded events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import signal
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..terminal import Terminal
|
|
12
|
+
from .decoder import InputDecoder
|
|
13
|
+
from .events import EventType, InputEvent
|
|
14
|
+
|
|
15
|
+
#: How often a platform without a resize signal asks the console for its size.
|
|
16
|
+
RESIZE_POLL_SECONDS = 0.25
|
|
17
|
+
|
|
18
|
+
#: How long the reader thread waits for input before checking whether to stop.
|
|
19
|
+
POLL_SECONDS = 0.1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncInput:
|
|
23
|
+
"""Publish decoded terminal input into one application queue."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, terminal: Terminal, publish: Callable[[InputEvent], None]) -> None:
|
|
26
|
+
"""Store the terminal, the publish sink, and a fresh decoder.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
terminal: Owns the input file descriptor.
|
|
30
|
+
publish: Called synchronously on the event loop for every decoded
|
|
31
|
+
event, including the synthetic resize.
|
|
32
|
+
"""
|
|
33
|
+
self.terminal = terminal
|
|
34
|
+
self.publish = publish
|
|
35
|
+
self.decoder = InputDecoder()
|
|
36
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
37
|
+
self._previous_resize_handler: Any = None
|
|
38
|
+
self._escape_handle: asyncio.TimerHandle | None = None
|
|
39
|
+
self._thread: threading.Thread | None = None
|
|
40
|
+
self._stopping = threading.Event()
|
|
41
|
+
self._resize_handle: asyncio.TimerHandle | None = None
|
|
42
|
+
self._size: tuple[int, int] | None = None
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
"""Attach the reader and the resize handler to the running loop."""
|
|
46
|
+
self.loop = asyncio.get_running_loop()
|
|
47
|
+
if self.terminal.console.watches_descriptor:
|
|
48
|
+
self.loop.add_reader(self.terminal.input_fd, self._read_ready)
|
|
49
|
+
if hasattr(signal, "SIGWINCH"):
|
|
50
|
+
self._previous_resize_handler = signal.getsignal(signal.SIGWINCH)
|
|
51
|
+
self.loop.add_signal_handler(signal.SIGWINCH, self._resize)
|
|
52
|
+
return
|
|
53
|
+
# A Windows console handle is not something the loop can watch, so a
|
|
54
|
+
# thread does the blocking read and hands chunks back to the loop; with
|
|
55
|
+
# no SIGWINCH either, the size is polled on the same loop.
|
|
56
|
+
self._stopping.clear()
|
|
57
|
+
self._thread = threading.Thread(target=self._pump, name="zettcode-input", daemon=True)
|
|
58
|
+
self._thread.start()
|
|
59
|
+
self._size = self.terminal.size
|
|
60
|
+
self._resize_handle = self.loop.call_later(RESIZE_POLL_SECONDS, self._poll_resize)
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Detach every loop hook and restore the previous resize handler."""
|
|
64
|
+
if self.loop is None:
|
|
65
|
+
return
|
|
66
|
+
if self.terminal.console.watches_descriptor:
|
|
67
|
+
self.loop.remove_reader(self.terminal.input_fd)
|
|
68
|
+
if self._thread is not None:
|
|
69
|
+
self._stopping.set()
|
|
70
|
+
self._thread.join(timeout=1.0)
|
|
71
|
+
self._thread = None
|
|
72
|
+
if self._resize_handle is not None:
|
|
73
|
+
self._resize_handle.cancel()
|
|
74
|
+
self._resize_handle = None
|
|
75
|
+
if hasattr(signal, "SIGWINCH") and self.terminal.console.watches_descriptor:
|
|
76
|
+
self.loop.remove_signal_handler(signal.SIGWINCH)
|
|
77
|
+
if self._escape_handle is not None:
|
|
78
|
+
self._escape_handle.cancel()
|
|
79
|
+
self._escape_handle = None
|
|
80
|
+
if (
|
|
81
|
+
self.terminal.console.watches_descriptor
|
|
82
|
+
and hasattr(signal, "SIGWINCH")
|
|
83
|
+
and callable(self._previous_resize_handler)
|
|
84
|
+
):
|
|
85
|
+
signal.signal(signal.SIGWINCH, self._previous_resize_handler)
|
|
86
|
+
self.loop = None
|
|
87
|
+
|
|
88
|
+
def _read_ready(self) -> None:
|
|
89
|
+
"""Read one chunk on the loop thread, when the loop watches the descriptor."""
|
|
90
|
+
try:
|
|
91
|
+
data = self.terminal.console.read()
|
|
92
|
+
except OSError:
|
|
93
|
+
return
|
|
94
|
+
self._feed(data)
|
|
95
|
+
|
|
96
|
+
def _feed(self, data: bytes) -> None:
|
|
97
|
+
"""Publish whatever one chunk decodes, and arm the escape timer."""
|
|
98
|
+
if self._escape_handle is not None:
|
|
99
|
+
self._escape_handle.cancel()
|
|
100
|
+
self._escape_handle = None
|
|
101
|
+
for event in self.decoder.feed(data):
|
|
102
|
+
self.publish(event)
|
|
103
|
+
# A trailing ESC may still be growing into a control sequence, so give
|
|
104
|
+
# it a moment (30 ms, the usual terminal timeout) before reporting the
|
|
105
|
+
# Escape key itself.
|
|
106
|
+
if self.decoder.buffer.startswith(b"\x1b") and self.loop is not None:
|
|
107
|
+
self._escape_handle = self.loop.call_later(0.03, self._flush_escape)
|
|
108
|
+
|
|
109
|
+
def _pump(self) -> None:
|
|
110
|
+
"""Read the console from a thread and hand each chunk to the loop."""
|
|
111
|
+
while not self._stopping.is_set():
|
|
112
|
+
try:
|
|
113
|
+
if not self.terminal.console.wait_readable(POLL_SECONDS):
|
|
114
|
+
continue
|
|
115
|
+
data = self.terminal.console.read()
|
|
116
|
+
except OSError:
|
|
117
|
+
return
|
|
118
|
+
if not data:
|
|
119
|
+
continue
|
|
120
|
+
loop = self.loop
|
|
121
|
+
if loop is not None:
|
|
122
|
+
loop.call_soon_threadsafe(self._feed, data)
|
|
123
|
+
|
|
124
|
+
def _poll_resize(self) -> None:
|
|
125
|
+
"""Publish a resize when the console's size changed.
|
|
126
|
+
|
|
127
|
+
Windows raises no SIGWINCH, so the loop asks instead of being told; the
|
|
128
|
+
probe is one console call, cheap enough for the frame interval.
|
|
129
|
+
"""
|
|
130
|
+
self._resize_handle = None
|
|
131
|
+
size = self.terminal.size
|
|
132
|
+
if self._size is not None and size != self._size:
|
|
133
|
+
self._size = size
|
|
134
|
+
self.publish(InputEvent(EventType.RESIZE))
|
|
135
|
+
if self.loop is not None:
|
|
136
|
+
self._resize_handle = self.loop.call_later(RESIZE_POLL_SECONDS, self._poll_resize)
|
|
137
|
+
|
|
138
|
+
def _resize(self) -> None:
|
|
139
|
+
"""Publish the resize the terminal raised through SIGWINCH."""
|
|
140
|
+
self.publish(InputEvent(EventType.RESIZE))
|
|
141
|
+
|
|
142
|
+
def _flush_escape(self) -> None:
|
|
143
|
+
"""Resolve a lone escape that never grew into a control sequence."""
|
|
144
|
+
self._escape_handle = None
|
|
145
|
+
# One event per call, so a run of escapes is reported press by press.
|
|
146
|
+
while (event := self.decoder.flush_escape()) is not None:
|
|
147
|
+
self.publish(event)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Layout containers: boxes, spacing, anchored overlays, and scrolling."""
|
|
2
|
+
|
|
3
|
+
from .box import HBox, Slot, VBox
|
|
4
|
+
from .overlay import Anchor, Overlay, OverlaySlot, centered
|
|
5
|
+
from .scroll import LineSource, ScrollView, StaticLines
|
|
6
|
+
from .solver import Track, resolve_tracks
|
|
7
|
+
from .spacing import Border, Padding
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Anchor",
|
|
11
|
+
"Border",
|
|
12
|
+
"HBox",
|
|
13
|
+
"LineSource",
|
|
14
|
+
"Overlay",
|
|
15
|
+
"OverlaySlot",
|
|
16
|
+
"Padding",
|
|
17
|
+
"ScrollView",
|
|
18
|
+
"Slot",
|
|
19
|
+
"StaticLines",
|
|
20
|
+
"Track",
|
|
21
|
+
"VBox",
|
|
22
|
+
"centered",
|
|
23
|
+
"resolve_tracks",
|
|
24
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Vertical and horizontal box containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ..core.geometry import Constraints, Point, Rect, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..render import Canvas
|
|
11
|
+
from .solver import Track, resolve_tracks
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Slot:
|
|
16
|
+
"""One child of a box, with its own sizing policy on the main axis.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
widget: The child laid out in this slot.
|
|
20
|
+
size: Fixed cell count on the main axis, or a callable receiving the
|
|
21
|
+
box's extent on the cross axis (the width of a ``VBox``, the height
|
|
22
|
+
of an ``HBox``) and returning main-axis cells. ``None`` contributes
|
|
23
|
+
nothing to the fixed budget, leaving the slot to ``flex``.
|
|
24
|
+
flex: Share of the leftover space; zero means the slot never grows.
|
|
25
|
+
min_size: Floor applied to the main-axis extent.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
widget: Widget
|
|
29
|
+
size: int | Callable[[int], int] | None = None
|
|
30
|
+
flex: int = 0
|
|
31
|
+
min_size: int = 0
|
|
32
|
+
|
|
33
|
+
def track(self) -> Track:
|
|
34
|
+
"""Describe this slot to the track solver on the main axis."""
|
|
35
|
+
return Track(self.size, self.flex, self.min_size)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Box(Widget):
|
|
39
|
+
"""Shared behavior for the two box directions."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, slots: Sequence[Slot]) -> None:
|
|
42
|
+
"""Store the slots in paint order."""
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.slots = tuple(slots)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def children(self) -> tuple[Widget, ...]:
|
|
48
|
+
"""Expose one child per slot."""
|
|
49
|
+
return tuple(slot.widget for slot in self.slots)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _vertical(self) -> bool:
|
|
53
|
+
"""Return whether children stack vertically; subclasses decide."""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def render(self, canvas: Canvas) -> None:
|
|
57
|
+
"""Paint every child in slot order; the box itself draws nothing."""
|
|
58
|
+
for slot in self.slots:
|
|
59
|
+
slot.widget.render(canvas)
|
|
60
|
+
|
|
61
|
+
def cursor(self) -> Point | None:
|
|
62
|
+
"""Return the first cursor any child asks for, in slot order."""
|
|
63
|
+
for slot in self.slots:
|
|
64
|
+
cursor = slot.widget.cursor()
|
|
65
|
+
if cursor is not None:
|
|
66
|
+
return cursor
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _cross_constraints(self, constraints: Constraints) -> Constraints:
|
|
70
|
+
"""Leave the main axis open and bound the cross axis by the parent."""
|
|
71
|
+
if self._vertical:
|
|
72
|
+
return Constraints(0, constraints.max_width, 0, None)
|
|
73
|
+
return Constraints(0, None, 0, constraints.max_height)
|
|
74
|
+
|
|
75
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
76
|
+
"""Sum the main-axis sizes and take the largest cross-axis child."""
|
|
77
|
+
inner = self._cross_constraints(constraints)
|
|
78
|
+
total = 0
|
|
79
|
+
widest = 0
|
|
80
|
+
tallest = 0
|
|
81
|
+
for slot in self.slots:
|
|
82
|
+
child = slot.widget.measure(inner)
|
|
83
|
+
natural = child.height if self._vertical else child.width
|
|
84
|
+
main = natural if slot.size is None or callable(slot.size) else slot.size
|
|
85
|
+
total += max(slot.min_size, main)
|
|
86
|
+
widest = max(widest, child.width)
|
|
87
|
+
tallest = max(tallest, child.height)
|
|
88
|
+
size = Size(widest, total) if self._vertical else Size(total, tallest)
|
|
89
|
+
return constraints.constrain(size)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class VBox(_Box):
|
|
93
|
+
"""Stack children top to bottom, sharing leftover height by flex.
|
|
94
|
+
|
|
95
|
+
Shape::
|
|
96
|
+
|
|
97
|
+
+-------------------+ <- the box's own rectangle
|
|
98
|
+
| header (size=1) | <- fixed rows keep their height
|
|
99
|
+
+-------------------+
|
|
100
|
+
| body (flex=1) | <- flexible rows share what is left
|
|
101
|
+
| |
|
|
102
|
+
+-------------------+
|
|
103
|
+
| status (size=1) |
|
|
104
|
+
+-------------------+
|
|
105
|
+
|
|
106
|
+
A fixed size may be a callable, which receives the box's *width* — a
|
|
107
|
+
wrapping child's height depends on the space across, not along. When the
|
|
108
|
+
fixed rows alone overflow, every row is scaled down proportionally so the
|
|
109
|
+
box never paints outside its rectangle.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def _vertical(self) -> bool:
|
|
114
|
+
"""Stack children top to bottom."""
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def layout(self, rect: Rect) -> None:
|
|
118
|
+
"""Split the height across the slots and stack them from the top."""
|
|
119
|
+
super().layout(rect)
|
|
120
|
+
y = rect.y
|
|
121
|
+
measured = resolve_tracks(rect.height, [s.track() for s in self.slots], cross=rect.width)
|
|
122
|
+
for slot, extent in zip(self.slots, measured, strict=True):
|
|
123
|
+
slot.widget.layout(Rect(rect.x, y, rect.width, extent))
|
|
124
|
+
y += extent
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class HBox(_Box):
|
|
128
|
+
"""Place children left to right, sharing leftover width by flex.
|
|
129
|
+
|
|
130
|
+
Shape::
|
|
131
|
+
|
|
132
|
+
+--------+---------------------------+
|
|
133
|
+
| left | right | <- size=8, then flex=1
|
|
134
|
+
+--------+---------------------------+
|
|
135
|
+
^ the callable form receives the box's *height*
|
|
136
|
+
|
|
137
|
+
The horizontal mirror of `VBox`: same track solver, same proportional
|
|
138
|
+
shrink when the fixed columns do not fit.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def _vertical(self) -> bool:
|
|
143
|
+
"""Place children left to right."""
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def layout(self, rect: Rect) -> None:
|
|
147
|
+
"""Split the width across the slots and place them from the left."""
|
|
148
|
+
super().layout(rect)
|
|
149
|
+
x = rect.x
|
|
150
|
+
measured = resolve_tracks(rect.width, [s.track() for s in self.slots], cross=rect.height)
|
|
151
|
+
for slot, extent in zip(self.slots, measured, strict=True):
|
|
152
|
+
slot.widget.layout(Rect(x, rect.y, extent, rect.height))
|
|
153
|
+
x += extent
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Anchored placement of children inside their parent's rectangle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ..core.geometry import Constraints, Rect, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..render import Canvas
|
|
11
|
+
|
|
12
|
+
HORIZONTAL = ("start", "center", "end", "stretch")
|
|
13
|
+
VERTICAL = ("start", "center", "end", "stretch")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Anchor:
|
|
18
|
+
"""Where a child sits inside its parent's bounds.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
horizontal: One of ``HORIZONTAL``. ``"stretch"`` fills the parent width,
|
|
22
|
+
``"center"`` and ``"end"`` offset the child inside the parent, and an
|
|
23
|
+
explicit ``width`` overrides both.
|
|
24
|
+
vertical: One of ``VERTICAL``, with the same meanings on the other axis.
|
|
25
|
+
offset_x: Cells added to the resolved left edge, after alignment.
|
|
26
|
+
offset_y: Cells added to the resolved top edge, after alignment.
|
|
27
|
+
width: Fixed width in cells; ``None`` uses the child's measured width.
|
|
28
|
+
Either way the value is capped to the parent's width.
|
|
29
|
+
height: Fixed height in cells; ``None`` uses the measured height.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
horizontal: str = "start"
|
|
33
|
+
vertical: str = "start"
|
|
34
|
+
offset_x: int = 0
|
|
35
|
+
offset_y: int = 0
|
|
36
|
+
width: int | None = None
|
|
37
|
+
height: int | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
"""Reject anchor names the resolver does not know."""
|
|
41
|
+
if self.horizontal not in HORIZONTAL:
|
|
42
|
+
raise ValueError(f"Unknown horizontal anchor: {self.horizontal!r}")
|
|
43
|
+
if self.vertical not in VERTICAL:
|
|
44
|
+
raise ValueError(f"Unknown vertical anchor: {self.vertical!r}")
|
|
45
|
+
|
|
46
|
+
def resolve(self, bounds: Rect, measured: Size) -> Rect:
|
|
47
|
+
"""Return the child rectangle this anchor picks inside bounds.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
bounds: Parent rectangle the child is placed inside.
|
|
51
|
+
measured: Size the child asked for; unused on an axis the anchor
|
|
52
|
+
stretches, and used otherwise when ``width``/``height`` is unset.
|
|
53
|
+
The result is capped to ``bounds`` either way.
|
|
54
|
+
"""
|
|
55
|
+
width = self._extent(self.horizontal, bounds.width, measured.width, self.width)
|
|
56
|
+
height = self._extent(self.vertical, bounds.height, measured.height, self.height)
|
|
57
|
+
x = bounds.x + self._offset(self.horizontal, bounds.width, width) + self.offset_x
|
|
58
|
+
y = bounds.y + self._offset(self.vertical, bounds.height, height) + self.offset_y
|
|
59
|
+
return Rect(x, y, max(0, width), max(0, height))
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _extent(axis: str, available: int, measured: int, pinned: int | None) -> int:
|
|
63
|
+
"""Return one axis extent, letting an explicit pin beat the anchor.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
axis: Anchor name for this axis.
|
|
67
|
+
available: Cells the parent offers on the axis.
|
|
68
|
+
measured: Size the child asked for.
|
|
69
|
+
pinned: Explicit width or height; ``None`` defers to the anchor, and
|
|
70
|
+
a pin is still capped to ``available``.
|
|
71
|
+
"""
|
|
72
|
+
if pinned is not None:
|
|
73
|
+
return min(pinned, available)
|
|
74
|
+
if axis == "stretch":
|
|
75
|
+
return available
|
|
76
|
+
return min(measured, available)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _offset(axis: str, available: int, extent: int) -> int:
|
|
80
|
+
"""Return the leading offset for one axis and placement mode."""
|
|
81
|
+
if axis == "center":
|
|
82
|
+
return max(0, (available - extent) // 2)
|
|
83
|
+
if axis == "end":
|
|
84
|
+
return max(0, available - extent)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class OverlaySlot:
|
|
90
|
+
"""One anchored child.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
widget: The child to place.
|
|
94
|
+
anchor: Where inside the overlay's rectangle the child goes.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
widget: Widget
|
|
98
|
+
anchor: Anchor = Anchor()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Overlay(Widget):
|
|
102
|
+
"""Stack children on top of each other at anchored positions.
|
|
103
|
+
|
|
104
|
+
Shape::
|
|
105
|
+
|
|
106
|
+
parent rectangle
|
|
107
|
+
+---------------------------+
|
|
108
|
+
| +--------+ |
|
|
109
|
+
| | child | | <- measured, then placed by its anchor
|
|
110
|
+
| +--------+ |
|
|
111
|
+
+---------------------------+
|
|
112
|
+
|
|
113
|
+
Later slots paint over earlier ones. Each child is measured against the full
|
|
114
|
+
rectangle first, so an anchor can align by the child's own size; the result
|
|
115
|
+
is always capped to the parent, and ``"stretch"`` fills it.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, slots: Sequence[OverlaySlot]) -> None:
|
|
119
|
+
"""Store the slots in paint order, so later slots sit on top.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
slots: Children to place; each is measured against the full overlay
|
|
123
|
+
rectangle before its anchor resolves the final position.
|
|
124
|
+
"""
|
|
125
|
+
super().__init__()
|
|
126
|
+
self.slots = tuple(slots)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def children(self) -> tuple[Widget, ...]:
|
|
130
|
+
"""Expose the anchored widgets in paint order."""
|
|
131
|
+
return tuple(slot.widget for slot in self.slots)
|
|
132
|
+
|
|
133
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
134
|
+
"""Report the smallest rectangle that fits every child."""
|
|
135
|
+
widest = 0
|
|
136
|
+
tallest = 0
|
|
137
|
+
for slot in self.slots:
|
|
138
|
+
child = slot.widget.measure(constraints)
|
|
139
|
+
widest = max(widest, child.width)
|
|
140
|
+
tallest = max(tallest, child.height)
|
|
141
|
+
return constraints.constrain(Size(widest, tallest))
|
|
142
|
+
|
|
143
|
+
def layout(self, rect: Rect) -> None:
|
|
144
|
+
"""Measure each child, then place it at its anchored rectangle."""
|
|
145
|
+
super().layout(rect)
|
|
146
|
+
for slot in self.slots:
|
|
147
|
+
measured = slot.widget.measure(Constraints.loose(rect.size))
|
|
148
|
+
slot.widget.layout(slot.anchor.resolve(rect, measured))
|
|
149
|
+
|
|
150
|
+
def render(self, canvas: Canvas) -> None:
|
|
151
|
+
"""Paint the children in slot order, so later slots cover earlier ones."""
|
|
152
|
+
for slot in self.slots:
|
|
153
|
+
slot.widget.render(canvas)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def centered(widget: Widget, *, horizontal: str = "center", vertical: str = "center") -> Overlay:
|
|
157
|
+
"""Wrap one widget in a full-rect overlay anchored at the given edges.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
widget: The single child to place.
|
|
161
|
+
horizontal: Horizontal anchor name, from ``"start"`` to ``"stretch"``.
|
|
162
|
+
vertical: Vertical anchor name, from ``"start"`` to ``"stretch"``.
|
|
163
|
+
"""
|
|
164
|
+
return Overlay([OverlaySlot(widget, Anchor(horizontal=horizontal, vertical=vertical))])
|