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,166 @@
|
|
|
1
|
+
"""Transient activity widgets: a spinner and a determinate bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
from ..core.geometry import Constraints, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..glyphs import BAR_EMPTY, BAR_FULL, SPINNER
|
|
11
|
+
from ..render import Canvas, Style
|
|
12
|
+
from ..render.text import display_width, truncate
|
|
13
|
+
|
|
14
|
+
DEFAULT_FRAMES = SPINNER
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Spinner(Widget):
|
|
18
|
+
"""An animated glyph that keeps the frame loop alive while it runs.
|
|
19
|
+
|
|
20
|
+
Shape::
|
|
21
|
+
|
|
22
|
+
<glyph> working <- braille frames, cycled every interval
|
|
23
|
+
|
|
24
|
+
The glyph is derived from the clock at paint time rather than advanced by a
|
|
25
|
+
tick, so it never drifts or speeds up when frames are dropped. What keeps
|
|
26
|
+
frames coming is the scheduler token registered in ``start``; with
|
|
27
|
+
``app.reduced_motion`` the token is skipped and the first frame stays put.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
label: str = "",
|
|
33
|
+
*,
|
|
34
|
+
frames: Sequence[str] = DEFAULT_FRAMES,
|
|
35
|
+
interval: float = 0.12,
|
|
36
|
+
clock=monotonic,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Configure the glyph cycle, the interval between frames, and the label.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
label: Text drawn after the glyph; omit it for a bare spinner.
|
|
42
|
+
frames: Glyphs cycled in order; an empty sequence falls back to
|
|
43
|
+
``DEFAULT_FRAMES``.
|
|
44
|
+
interval: Seconds each glyph is held, floored at 0.01.
|
|
45
|
+
clock: Time source, injectable so tests can step the animation.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.label = label
|
|
49
|
+
self.frames = tuple(frames) or DEFAULT_FRAMES
|
|
50
|
+
self.interval = max(0.01, interval)
|
|
51
|
+
self.clock = clock
|
|
52
|
+
self._started = 0.0
|
|
53
|
+
self._active = False
|
|
54
|
+
self._token = f"spinner:{id(self)}"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def active(self) -> bool:
|
|
58
|
+
"""Return whether the spinner is registered for animation."""
|
|
59
|
+
return self._active
|
|
60
|
+
|
|
61
|
+
def start(self) -> None:
|
|
62
|
+
"""Start animating, unless reduced motion is in effect."""
|
|
63
|
+
self._active = True
|
|
64
|
+
self._started = self.clock()
|
|
65
|
+
if self.app is not None and not self.app.reduced_motion:
|
|
66
|
+
self.app.scheduler.animate(self._token)
|
|
67
|
+
|
|
68
|
+
def stop(self) -> None:
|
|
69
|
+
"""Stop animating and release the scheduler token."""
|
|
70
|
+
self._active = False
|
|
71
|
+
if self.app is not None:
|
|
72
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
"""Resume the animation when an already running spinner is mounted."""
|
|
76
|
+
if self._active:
|
|
77
|
+
self.start()
|
|
78
|
+
|
|
79
|
+
def on_unmount(self) -> None:
|
|
80
|
+
"""Release the token so a detached spinner stops costing frames."""
|
|
81
|
+
self.stop()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def frame(self) -> str:
|
|
85
|
+
"""Return the glyph for the current time, or the first frame when idle."""
|
|
86
|
+
if not self._active or (self.app is not None and self.app.reduced_motion):
|
|
87
|
+
return self.frames[0]
|
|
88
|
+
step = int((self.clock() - self._started) / self.interval)
|
|
89
|
+
return self.frames[step % len(self.frames)]
|
|
90
|
+
|
|
91
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
92
|
+
"""Ask for one row and the glyph plus label."""
|
|
93
|
+
return constraints.constrain(Size(1 + (1 + display_width(self.label) if self.label else 0), 1))
|
|
94
|
+
|
|
95
|
+
def render(self, canvas: Canvas) -> None:
|
|
96
|
+
"""Paint the current glyph and label."""
|
|
97
|
+
if self.rect.empty:
|
|
98
|
+
return
|
|
99
|
+
theme = self.theme
|
|
100
|
+
text = f"{self.frame} {self.label}" if self.label else self.frame
|
|
101
|
+
canvas.draw_text(
|
|
102
|
+
self.rect.x,
|
|
103
|
+
self.rect.y,
|
|
104
|
+
truncate(text, self.rect.width),
|
|
105
|
+
Style(foreground=theme.accent, bold=True),
|
|
106
|
+
max_width=self.rect.width,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ProgressBar(Widget):
|
|
111
|
+
"""A determinate bar with an optional label.
|
|
112
|
+
|
|
113
|
+
Shape::
|
|
114
|
+
|
|
115
|
+
build ##########------------ <- label, filled, empty
|
|
116
|
+
|<-- width * value -->|
|
|
117
|
+
|
|
118
|
+
``value`` is clamped into 0..1; filled cells use U+2588 and empty ones
|
|
119
|
+
U+2591. The label is truncated first and always keeps one cell for the bar;
|
|
120
|
+
if nothing is left, only the label is drawn.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, value: float = 0.0, *, label: str = "", filled: str = BAR_FULL, empty: str = BAR_EMPTY) -> None:
|
|
124
|
+
"""Store the value and the glyphs used for the filled and empty parts.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
value: Completion ratio; clamped into ``0.0..1.0`` at paint time.
|
|
128
|
+
label: Text drawn before the bar, followed by one space.
|
|
129
|
+
filled: Glyph repeated for the completed portion of the bar.
|
|
130
|
+
empty: Glyph repeated for the remaining portion.
|
|
131
|
+
"""
|
|
132
|
+
super().__init__()
|
|
133
|
+
self.value = value
|
|
134
|
+
self.label = label
|
|
135
|
+
self.filled = filled
|
|
136
|
+
self.empty = empty
|
|
137
|
+
|
|
138
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
139
|
+
"""Ask for one row and the label plus one cell of bar."""
|
|
140
|
+
return constraints.constrain(Size(1 + display_width(self.label) + (1 if self.label else 0), 1))
|
|
141
|
+
|
|
142
|
+
def render(self, canvas: Canvas) -> None:
|
|
143
|
+
"""Paint the label and the bar, clamping the value into 0..1."""
|
|
144
|
+
if self.rect.empty:
|
|
145
|
+
return
|
|
146
|
+
theme = self.theme
|
|
147
|
+
width = self.rect.width
|
|
148
|
+
prefix = ""
|
|
149
|
+
if self.label:
|
|
150
|
+
prefix = truncate(f"{self.label} ", max(0, width - 1))
|
|
151
|
+
width -= display_width(prefix)
|
|
152
|
+
if width <= 0:
|
|
153
|
+
canvas.draw_text(self.rect.x, self.rect.y, prefix, Style(foreground=theme.text), max_width=self.rect.width)
|
|
154
|
+
return
|
|
155
|
+
ratio = min(1.0, max(0.0, self.value))
|
|
156
|
+
filled = min(width, int(round(width * ratio)))
|
|
157
|
+
canvas.draw_text(self.rect.x, self.rect.y, prefix, Style(foreground=theme.text), max_width=self.rect.width)
|
|
158
|
+
x = self.rect.x + display_width(prefix)
|
|
159
|
+
canvas.draw_text(x, self.rect.y, self.filled * filled, Style(foreground=theme.accent), max_width=filled)
|
|
160
|
+
canvas.draw_text(
|
|
161
|
+
x + filled,
|
|
162
|
+
self.rect.y,
|
|
163
|
+
self.empty * (width - filled),
|
|
164
|
+
Style(foreground=theme.border),
|
|
165
|
+
max_width=width - filled,
|
|
166
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A retained widget for small styled text blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import TypeAlias, cast
|
|
7
|
+
|
|
8
|
+
from ..core.geometry import Constraints, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..render import Canvas, Span, Style, TextLine
|
|
11
|
+
from ..render.rich_text import Alignment, layout_rich_lines
|
|
12
|
+
from ..render.text import display_width
|
|
13
|
+
|
|
14
|
+
RichContent: TypeAlias = str | Span | TextLine | Sequence[Span] | Sequence[TextLine]
|
|
15
|
+
RichValue: TypeAlias = RichContent | Callable[[], RichContent]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RichText(Widget):
|
|
19
|
+
"""Draw a small block of styled lines, with optional wrapping and alignment.
|
|
20
|
+
|
|
21
|
+
A value may be a string, a Span, a TextLine, a sequence of spans or
|
|
22
|
+
lines, or a callable evaluated at paint time. Long histories instead
|
|
23
|
+
use a LineSource and the same ``layout_rich_lines`` function, without
|
|
24
|
+
mounting a widget for each message.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
value: Text or lines to show, optionally computed on each paint.
|
|
28
|
+
wrap: Wrap within the given width; otherwise clip with an ellipsis.
|
|
29
|
+
align: ``"left"``, ``"center"``, or ``"right"``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, value: RichValue = "", *, wrap: bool = True, align: Alignment = "left") -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
if align not in ("left", "center", "right"):
|
|
35
|
+
raise ValueError(f"Unknown alignment: {align!r}")
|
|
36
|
+
self.value = value
|
|
37
|
+
self.wrap = wrap
|
|
38
|
+
self.align = align
|
|
39
|
+
|
|
40
|
+
def set_content(self, value: RichValue) -> None:
|
|
41
|
+
"""Replace the content and ask the parent to measure it again."""
|
|
42
|
+
self.value = value
|
|
43
|
+
self.request_layout()
|
|
44
|
+
if self.app is not None:
|
|
45
|
+
self.app.request_layout()
|
|
46
|
+
|
|
47
|
+
def _lines(self) -> tuple[TextLine, ...]:
|
|
48
|
+
"""Resolve the current value into immutable styled lines."""
|
|
49
|
+
value = self.value() if callable(self.value) else self.value
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
style = Style(foreground=self.theme.text)
|
|
52
|
+
return tuple(TextLine((Span(line, style),)) for line in value.split("\n"))
|
|
53
|
+
if isinstance(value, Span):
|
|
54
|
+
return (TextLine((value,)),)
|
|
55
|
+
if isinstance(value, TextLine):
|
|
56
|
+
return (value,)
|
|
57
|
+
items: tuple[Span | TextLine, ...] = tuple(value)
|
|
58
|
+
if not items:
|
|
59
|
+
return ()
|
|
60
|
+
if isinstance(items[0], TextLine):
|
|
61
|
+
if not all(isinstance(item, TextLine) for item in items):
|
|
62
|
+
raise TypeError("RichText content cannot mix TextLine and Span")
|
|
63
|
+
return cast(tuple[TextLine, ...], items)
|
|
64
|
+
if not all(isinstance(item, Span) for item in items):
|
|
65
|
+
raise TypeError("RichText content must be Span or TextLine values")
|
|
66
|
+
return (TextLine(cast(tuple[Span, ...], items)),)
|
|
67
|
+
|
|
68
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
69
|
+
"""Measure the text by visible columns and wrapped line count."""
|
|
70
|
+
lines = self._lines()
|
|
71
|
+
if not lines:
|
|
72
|
+
return constraints.constrain(Size(0, 0))
|
|
73
|
+
natural = max(display_width(line.text) for line in lines)
|
|
74
|
+
width = min(natural, constraints.max_width) if constraints.max_width is not None else natural
|
|
75
|
+
height = len(layout_rich_lines(lines, max(1, width), wrap=self.wrap, align=self.align))
|
|
76
|
+
return constraints.constrain(Size(width, height))
|
|
77
|
+
|
|
78
|
+
def render(self, canvas: Canvas) -> None:
|
|
79
|
+
"""Paint only rows inside the assigned rectangle."""
|
|
80
|
+
if self.rect.empty:
|
|
81
|
+
return
|
|
82
|
+
rows = layout_rich_lines(self._lines(), self.rect.width, wrap=self.wrap, align=self.align)
|
|
83
|
+
for offset, row in enumerate(rows[: self.rect.height]):
|
|
84
|
+
canvas.draw_spans(self.rect.x, self.rect.y + offset, row.spans, max_width=self.rect.width)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""One-line status bar with left and right segments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from ..core.geometry import Constraints, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..render import DEFAULT_STYLE, Canvas, Span, Style, TextLine, truncate_spans
|
|
11
|
+
|
|
12
|
+
Text: TypeAlias = str | TextLine | Callable[[], str | TextLine]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#: Columns kept empty between the two segments. Without it a long left segment runs into the right one and the
|
|
16
|
+
#: row reads as a single run of words; the gap is what says which part belongs to which side.
|
|
17
|
+
GAP = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatusBar(Widget):
|
|
21
|
+
"""Render a left-aligned and a right-aligned segment on one row.
|
|
22
|
+
|
|
23
|
+
Shape::
|
|
24
|
+
|
|
25
|
+
zettcode openai/gpt-5 <- right segment, flush to the edge
|
|
26
|
+
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^
|
|
27
|
+
left segment never truncated
|
|
28
|
+
|
|
29
|
+
The right segment wins when space runs out: it keeps its width and the left
|
|
30
|
+
one is cut instead. A right segment wider than the whole row is drawn alone.
|
|
31
|
+
Either segment may be a callable, read once per paint.
|
|
32
|
+
|
|
33
|
+
At least :data:`GAP` columns are left empty between the two. A left segment
|
|
34
|
+
with no room for even one character next to that gap is dropped rather than
|
|
35
|
+
drawn as a stray ellipsis against the right segment, so a narrow row says
|
|
36
|
+
what the row is about and nothing half-written.
|
|
37
|
+
|
|
38
|
+
A segment can be a styled :class:`~zettcode.tui.render.TextLine`; its spans
|
|
39
|
+
keep their own colours and attributes. A span that sets no style of its own
|
|
40
|
+
is painted with the bar's muted style, so a plain ``str``, an unstyled line,
|
|
41
|
+
and the golden default all look the same.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, left: Text = "", right: Text = "") -> None:
|
|
45
|
+
"""Store both segments; either may be a callback evaluated at paint time.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
left: Drawn at the left edge, truncated when the right segment wins.
|
|
49
|
+
right: Drawn flush to the right edge; it is never truncated, so it
|
|
50
|
+
keeps the width it asks for even at the left segment's expense.
|
|
51
|
+
"""
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.left = left
|
|
54
|
+
self.right = right
|
|
55
|
+
|
|
56
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
57
|
+
"""Ask for one row and the combined width of both segments."""
|
|
58
|
+
wanted = self._line(self.left).width + self._line(self.right).width
|
|
59
|
+
return constraints.constrain(Size(wanted, 1))
|
|
60
|
+
|
|
61
|
+
def render(self, canvas: Canvas) -> None:
|
|
62
|
+
"""Draw both segments, letting the right one win when space runs out."""
|
|
63
|
+
if self.rect.empty:
|
|
64
|
+
return
|
|
65
|
+
left = self._line(self.left)
|
|
66
|
+
right = self._line(self.right)
|
|
67
|
+
right_width = right.width
|
|
68
|
+
if right_width >= self.rect.width:
|
|
69
|
+
canvas.draw_spans(
|
|
70
|
+
self.rect.x,
|
|
71
|
+
self.rect.y,
|
|
72
|
+
truncate_spans(right.spans, self.rect.width),
|
|
73
|
+
max_width=self.rect.width,
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
room = self.rect.width - right_width
|
|
77
|
+
if room <= GAP:
|
|
78
|
+
# Less than one column beside the gap: the right segment is the whole row.
|
|
79
|
+
canvas.draw_spans(
|
|
80
|
+
self.rect.x + self.rect.width - right_width,
|
|
81
|
+
self.rect.y,
|
|
82
|
+
right.spans,
|
|
83
|
+
max_width=right_width,
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
room -= GAP
|
|
87
|
+
canvas.draw_spans(self.rect.x, self.rect.y, truncate_spans(left.spans, room), max_width=room)
|
|
88
|
+
canvas.draw_spans(
|
|
89
|
+
self.rect.x + self.rect.width - right_width,
|
|
90
|
+
self.rect.y,
|
|
91
|
+
right.spans,
|
|
92
|
+
max_width=right_width,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _line(self, text: Text) -> TextLine:
|
|
96
|
+
"""Resolve a segment to a styled line, applying the bar style to bare spans."""
|
|
97
|
+
value = text() if callable(text) else text
|
|
98
|
+
fallback = Style(foreground=self.theme.muted)
|
|
99
|
+
if isinstance(value, TextLine):
|
|
100
|
+
return TextLine(tuple(self._tint(span, fallback) for span in value.spans), metadata=value.metadata)
|
|
101
|
+
return TextLine((Span(value, fallback),))
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _tint(span: Span, fallback: Style) -> Span:
|
|
105
|
+
"""Give an unstyled span the bar's style, leaving designed spans alone."""
|
|
106
|
+
return span if span.style != DEFAULT_STYLE else Span(span.text, fallback)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""A table widget that paints the same shape as a Markdown table."""
|
|
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, Size
|
|
9
|
+
from ..core.widget import Widget
|
|
10
|
+
from ..render import Canvas
|
|
11
|
+
from ..render.text import display_width
|
|
12
|
+
from .markdown import render_table
|
|
13
|
+
|
|
14
|
+
ALIGNMENTS = ("left", "center", "right")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Column:
|
|
19
|
+
"""One table column and how its cells line up.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
title: Header text; an empty title on every column hides the header row.
|
|
23
|
+
align: One of ``ALIGNMENTS``; anything else is rejected.
|
|
24
|
+
width: Pins the column to a fixed cell count. It is still raised to the
|
|
25
|
+
renderer's minimum and shrunk when the width budget is tight;
|
|
26
|
+
``None`` lets the column take its natural width.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
title: str = ""
|
|
30
|
+
align: str = "left"
|
|
31
|
+
width: int | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
"""Reject alignments the table renderer does not know."""
|
|
35
|
+
if self.align not in ALIGNMENTS:
|
|
36
|
+
raise ValueError(f"Unknown alignment: {self.align!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Table(Widget):
|
|
40
|
+
"""Lay rows out in columns that always fit the widget rectangle.
|
|
41
|
+
|
|
42
|
+
Rendering is delegated to the Markdown table renderer so a table looks the
|
|
43
|
+
same wherever it comes from: a header, one rule per column, and cells that
|
|
44
|
+
wrap instead of being cut. ``Column.width`` pins a column when needed.
|
|
45
|
+
|
|
46
|
+
Shape::
|
|
47
|
+
|
|
48
|
+
Name Files State
|
|
49
|
+
--------- ----- ----- <- one rule per column, dim
|
|
50
|
+
parser 12 done <- per-column alignment
|
|
51
|
+
renderer 7 wip
|
|
52
|
+
|
|
53
|
+
The rule is U+2500. Columns are dropped from the right when they cannot fit,
|
|
54
|
+
and the header row disappears when every title is empty.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
columns: Sequence[Column],
|
|
60
|
+
rows: Sequence[Sequence[str]] = (),
|
|
61
|
+
*,
|
|
62
|
+
header: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Store the columns and rows; a header hides itself when no title is set.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
columns: Column definitions, left to right.
|
|
68
|
+
rows: Cell text, padded and truncated to the column count.
|
|
69
|
+
header: Draw the title row; ignored when every title is empty.
|
|
70
|
+
"""
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.columns = tuple(columns)
|
|
73
|
+
self.rows = tuple(tuple(row) for row in rows)
|
|
74
|
+
self.header = header and any(column.title for column in self.columns)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def body_height(self) -> int:
|
|
78
|
+
"""Return how many body rows the table holds."""
|
|
79
|
+
return len(self.rows)
|
|
80
|
+
|
|
81
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
82
|
+
"""Estimate the natural width from the titles and one row per entry."""
|
|
83
|
+
widest = sum(display_width(column.title) + 3 for column in self.columns)
|
|
84
|
+
return constraints.constrain(Size(widest, len(self.rows) + int(self.header)))
|
|
85
|
+
|
|
86
|
+
def render(self, canvas: Canvas) -> None:
|
|
87
|
+
"""Delegate to the Markdown renderer, dropping the header when it is hidden."""
|
|
88
|
+
if not self.columns or self.rect.empty:
|
|
89
|
+
return
|
|
90
|
+
lines = render_table(
|
|
91
|
+
[column.title for column in self.columns],
|
|
92
|
+
[column.align for column in self.columns],
|
|
93
|
+
[list(row) for row in self.rows],
|
|
94
|
+
self.rect.width,
|
|
95
|
+
self.theme,
|
|
96
|
+
fixed=[column.width for column in self.columns],
|
|
97
|
+
)
|
|
98
|
+
if not self.header:
|
|
99
|
+
lines = lines[2:]
|
|
100
|
+
for index, line in enumerate(lines[: self.rect.height]):
|
|
101
|
+
canvas.draw_spans(self.rect.x, self.rect.y + index, line.spans, max_width=self.rect.width)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""A compact panel of ordered tasks with one of them in progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from zettcode.tui import DONE, MARKER, PENDING, RULE
|
|
8
|
+
|
|
9
|
+
from ..core.geometry import Constraints, Size
|
|
10
|
+
from ..core.widget import Widget
|
|
11
|
+
from ..render import Canvas, Style, truncate
|
|
12
|
+
from ..render.text import display_width
|
|
13
|
+
|
|
14
|
+
MARKERS = {
|
|
15
|
+
"completed": DONE,
|
|
16
|
+
"in_progress": MARKER,
|
|
17
|
+
"pending": PENDING,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskPanel(Widget):
|
|
22
|
+
"""Show ordered tasks; a panel with no tasks occupies no space.
|
|
23
|
+
|
|
24
|
+
The widget only knows about a state name and a label, which keeps the
|
|
25
|
+
framework free of any particular task vocabulary.
|
|
26
|
+
|
|
27
|
+
Shape::
|
|
28
|
+
|
|
29
|
+
<- blank row, so the box is not glued
|
|
30
|
+
\u250c Plan \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 to the transcript above
|
|
31
|
+
\u2502 \u2713 read the renderer \u2502 <- completed: muted, struck out
|
|
32
|
+
\u2502 \u25b8 preview widgets \u2502 <- in_progress: accent, bold, U+25B8
|
|
33
|
+
\u2502 \u00b7 run make check \u2502 <- pending: body text, U+00B7
|
|
34
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
35
|
+
|
|
36
|
+
An unknown state falls back to the pending dot, and ``preferred_height()``
|
|
37
|
+
is the slot size an owner passes to keep the panel exactly as tall as its
|
|
38
|
+
contents (zero rows when there is nothing to show). A panel too narrow for a
|
|
39
|
+
frame drops it and keeps the rows.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
#: Frame glyphs; the title rides in the top edge to save a row.
|
|
43
|
+
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT = "\u250c", "\u2510", "\u2514", "\u2518"
|
|
44
|
+
HORIZONTAL, VERTICAL = RULE, "\u2502"
|
|
45
|
+
#: Cells of frame around the rows, and the blank row above the frame.
|
|
46
|
+
FRAME_COLUMNS = 4
|
|
47
|
+
SEPARATOR_ROWS = 1
|
|
48
|
+
|
|
49
|
+
def __init__(self, title: str = "Plan") -> None:
|
|
50
|
+
"""Start empty; the panel occupies no space until tasks arrive.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
title: Heading drawn above the rows, indented by two spaces.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.title = title
|
|
57
|
+
self.tasks: tuple[tuple[str, str], ...] = ()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def visible(self) -> bool:
|
|
61
|
+
"""Return whether the panel has anything to show."""
|
|
62
|
+
return bool(self.tasks)
|
|
63
|
+
|
|
64
|
+
def set_tasks(self, tasks: Sequence[tuple[str, str]]) -> bool:
|
|
65
|
+
"""Replace the list and report whether anything actually changed.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
tasks: ``(state, label)`` pairs in display order. ``state`` is looked
|
|
69
|
+
up in ``MARKERS`` for the glyph and for the colour; an unknown
|
|
70
|
+
state falls back to a neutral dot.
|
|
71
|
+
"""
|
|
72
|
+
updated = tuple((state, label) for state, label in tasks)
|
|
73
|
+
if updated == self.tasks:
|
|
74
|
+
return False
|
|
75
|
+
self.tasks = updated
|
|
76
|
+
self.invalidate()
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def preferred_height(self) -> int:
|
|
80
|
+
"""Return the rows needed for the separator, the frame, and the tasks."""
|
|
81
|
+
return len(self.tasks) + 3 if self.tasks else 0
|
|
82
|
+
|
|
83
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
84
|
+
"""Ask for the widest task label plus the marker and the frame."""
|
|
85
|
+
width = max((display_width(label) for _, label in self.tasks), default=0) + self.FRAME_COLUMNS
|
|
86
|
+
return constraints.constrain(Size(width, self.preferred_height()))
|
|
87
|
+
|
|
88
|
+
def render(self, canvas: Canvas) -> None:
|
|
89
|
+
"""Paint the frame, one row per task, and the heading in the top edge."""
|
|
90
|
+
if not self.tasks or self.rect.empty:
|
|
91
|
+
return
|
|
92
|
+
theme = self.theme
|
|
93
|
+
frame = Style(foreground=theme.border)
|
|
94
|
+
heading = Style(foreground=theme.muted, bold=True)
|
|
95
|
+
# The separator row is outside the frame, so the box never sits flush
|
|
96
|
+
# against the transcript it follows.
|
|
97
|
+
top = self.rect.y + self.SEPARATOR_ROWS
|
|
98
|
+
width = self.rect.width
|
|
99
|
+
label = f" {self.title} "
|
|
100
|
+
framed = width >= 4 + display_width(label) and self.rect.height > self.SEPARATOR_ROWS + 2
|
|
101
|
+
if framed:
|
|
102
|
+
rule = self.HORIZONTAL * max(0, width - 2 - display_width(label))
|
|
103
|
+
canvas.draw_text(self.rect.x, top, self.TOP_LEFT, frame)
|
|
104
|
+
canvas.draw_text(self.rect.x + 1, top, label, heading, max_width=display_width(label))
|
|
105
|
+
canvas.draw_text(self.rect.x + 1 + display_width(label), top, f"{rule}{self.TOP_RIGHT}", frame)
|
|
106
|
+
for offset, (state, text) in enumerate(self.tasks):
|
|
107
|
+
row = top + offset + (1 if framed else 0)
|
|
108
|
+
if row >= self.rect.y + self.rect.height:
|
|
109
|
+
return
|
|
110
|
+
marker = MARKERS.get(state, PENDING)
|
|
111
|
+
if state == "completed":
|
|
112
|
+
# A finished task is struck out as well as dimmed, so a long
|
|
113
|
+
# plan shows what is left at a glance.
|
|
114
|
+
style = Style(foreground=theme.muted, strike=True)
|
|
115
|
+
elif state == "in_progress":
|
|
116
|
+
style = Style(foreground=theme.accent, bold=True)
|
|
117
|
+
else:
|
|
118
|
+
style = Style(foreground=theme.text)
|
|
119
|
+
if not framed:
|
|
120
|
+
canvas.draw_text(self.rect.x, row, truncate(f" {marker} {text}", width), style, max_width=width)
|
|
121
|
+
continue
|
|
122
|
+
canvas.draw_text(self.rect.x, row, self.VERTICAL, frame)
|
|
123
|
+
room = max(0, width - self.FRAME_COLUMNS)
|
|
124
|
+
canvas.draw_text(self.rect.x + 2, row, truncate(f"{marker} {text}", room), style, max_width=room)
|
|
125
|
+
canvas.draw_text(self.rect.x + width - 1, row, self.VERTICAL, frame)
|
|
126
|
+
if framed:
|
|
127
|
+
row = top + len(self.tasks) + 1
|
|
128
|
+
if row < self.rect.y + self.rect.height:
|
|
129
|
+
floor = f"{self.BOTTOM_LEFT}{self.HORIZONTAL * max(0, width - 2)}{self.BOTTOM_RIGHT}"
|
|
130
|
+
canvas.draw_text(self.rect.x, row, floor, frame, max_width=width)
|