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
zettcode/tui/gallery.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""Preview every built-in widget as a frame that can be printed or paged through.
|
|
2
|
+
|
|
3
|
+
The gallery is headless by design: an entry is a name, a one-line summary, the
|
|
4
|
+
size it wants, and a factory that builds the widget. :func:`render_entry` mounts
|
|
5
|
+
one widget in a throwaway :class:`~zettcode.tui.core.app.TuiApp` and
|
|
6
|
+
paints a single frame, and :func:`print_gallery` writes those frames to a
|
|
7
|
+
stream. That is what lets ``make demo-list`` show a component with no TTY and no
|
|
8
|
+
test harness in the way.
|
|
9
|
+
|
|
10
|
+
Previews are snapshots: a spinner shows one glyph and a toast one frame of its
|
|
11
|
+
countdown, because nothing ticks the clock in a one-shot render.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
from .core.app import TuiApp
|
|
24
|
+
from .core.events import AnyEvent
|
|
25
|
+
from .core.geometry import Point, Rect, Size
|
|
26
|
+
from .core.host import Host
|
|
27
|
+
from .core.screen import Screen
|
|
28
|
+
from .core.theme import DARK, Theme
|
|
29
|
+
from .core.widget import Widget
|
|
30
|
+
from .glyphs import PROMPT, SEPARATOR
|
|
31
|
+
from .layout import Anchor, Border, HBox, Overlay, OverlaySlot, Padding, ScrollView, Slot, StaticLines, VBox
|
|
32
|
+
from .render import Canvas, ColorDepth, Span, Style, TextLine, encode_style
|
|
33
|
+
from .widgets import (
|
|
34
|
+
Collapsible,
|
|
35
|
+
Column,
|
|
36
|
+
CompletionItem,
|
|
37
|
+
CompletionPopup,
|
|
38
|
+
Dialog,
|
|
39
|
+
DialogAction,
|
|
40
|
+
DiffView,
|
|
41
|
+
ListItem,
|
|
42
|
+
ListPage,
|
|
43
|
+
ListView,
|
|
44
|
+
MarkdownView,
|
|
45
|
+
ProgressBar,
|
|
46
|
+
RichText,
|
|
47
|
+
Rule,
|
|
48
|
+
Spinner,
|
|
49
|
+
StatusBar,
|
|
50
|
+
Table,
|
|
51
|
+
TaskPanel,
|
|
52
|
+
Text,
|
|
53
|
+
TextArea,
|
|
54
|
+
Toast,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
MARKDOWN_SAMPLE = """\
|
|
58
|
+
## Widget gallery
|
|
59
|
+
|
|
60
|
+
Prose wraps to the pane, and inline **bold**, `code`, and [links](https://example.com)
|
|
61
|
+
keep their own styling.
|
|
62
|
+
|
|
63
|
+
- bullet one
|
|
64
|
+
- bullet two
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
def add(a, b):
|
|
68
|
+
return a + b
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
DIFF_BEFORE = """\
|
|
73
|
+
def total(items):
|
|
74
|
+
value = 0
|
|
75
|
+
for item in items:
|
|
76
|
+
value = value + item
|
|
77
|
+
return value
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
DIFF_AFTER = """\
|
|
81
|
+
def total(items):
|
|
82
|
+
return sum(item.price for item in items)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
SCROLL_LINES = tuple(f"line {index:02d}" for index in range(1, 21))
|
|
86
|
+
|
|
87
|
+
SIDEBAR = 30
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class GalleryEntry:
|
|
92
|
+
"""One component in the gallery.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
name: Target name, used by ``make demo-<name>`` and the script argument.
|
|
96
|
+
summary: One line describing what the frame shows.
|
|
97
|
+
size: Frame size in cells; the widget is laid out into exactly this
|
|
98
|
+
rectangle, so an entry shows its natural shape.
|
|
99
|
+
build: Builds a fresh widget per render, so state never leaks between
|
|
100
|
+
previews and a factory may preconfigure a widget before it is mounted.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
summary: str
|
|
105
|
+
size: Size
|
|
106
|
+
build: Callable[[], Widget]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _textarea() -> TextArea:
|
|
110
|
+
"""Return the composer sample, pre-filled so wrapping and the prompt show."""
|
|
111
|
+
area = TextArea(prompt=f"{PROMPT} ", placeholder="ask anything", completions=("/clear", "/quit"))
|
|
112
|
+
area.set_text("explain the diff widget\nand keep it short")
|
|
113
|
+
return area
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _task_panel() -> TaskPanel:
|
|
117
|
+
"""Return the plan panel with one task in each state."""
|
|
118
|
+
panel = TaskPanel()
|
|
119
|
+
panel.set_tasks(
|
|
120
|
+
(("completed", "read the renderer"), ("in_progress", "preview widgets"), ("pending", "run make check"))
|
|
121
|
+
)
|
|
122
|
+
return panel
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _layout_sample() -> Widget:
|
|
126
|
+
"""Return a frame exercising boxes, spacing, borders, and anchored overlays."""
|
|
127
|
+
left = VBox(
|
|
128
|
+
[
|
|
129
|
+
Slot(Text("VBox slot 1", bold=True), size=1),
|
|
130
|
+
Slot(Text("VBox slot 2", muted=True), size=1),
|
|
131
|
+
Slot(Padding(Text("Padding(1)"), 1), flex=1),
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
right = Overlay(
|
|
135
|
+
[
|
|
136
|
+
OverlaySlot(
|
|
137
|
+
Text("center"),
|
|
138
|
+
Anchor(horizontal="center", vertical="center"),
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
return HBox(
|
|
143
|
+
[
|
|
144
|
+
Slot(Border(left, title=" VBox + Padding "), flex=1),
|
|
145
|
+
Slot(Border(right, title=" Overlay center "), flex=1),
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
GALLERY: tuple[GalleryEntry, ...] = (
|
|
151
|
+
GalleryEntry(
|
|
152
|
+
"text",
|
|
153
|
+
"Text and Rule: bold, muted, wrapping, and right alignment",
|
|
154
|
+
Size(46, 4),
|
|
155
|
+
lambda: VBox(
|
|
156
|
+
[
|
|
157
|
+
Slot(Text("bold heading", bold=True), size=1),
|
|
158
|
+
Slot(Rule(), size=1),
|
|
159
|
+
Slot(Text("muted caption that wraps across the remaining rows", muted=True), flex=1),
|
|
160
|
+
]
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
GalleryEntry(
|
|
164
|
+
"status_bar",
|
|
165
|
+
"StatusBar: one row, left segment plus right segment",
|
|
166
|
+
Size(46, 1),
|
|
167
|
+
lambda: StatusBar(" zettcode", "openai/gpt-5 "),
|
|
168
|
+
),
|
|
169
|
+
GalleryEntry("spinner", "Spinner: animated glyph with a label", Size(24, 1), lambda: Spinner("working")),
|
|
170
|
+
GalleryEntry(
|
|
171
|
+
"progress_bar",
|
|
172
|
+
"ProgressBar: determinate bar with a label",
|
|
173
|
+
Size(30, 1),
|
|
174
|
+
lambda: ProgressBar(0.42, label="build"),
|
|
175
|
+
),
|
|
176
|
+
GalleryEntry(
|
|
177
|
+
"list",
|
|
178
|
+
"ListView: marker, description, and a disabled row",
|
|
179
|
+
Size(34, 4),
|
|
180
|
+
lambda: ListView(
|
|
181
|
+
[
|
|
182
|
+
ListItem("a", label="alpha", description="first"),
|
|
183
|
+
ListItem("b", label="beta", description="second"),
|
|
184
|
+
ListItem("c", label="gamma", disabled=True),
|
|
185
|
+
ListItem("d", label="delta", description="fourth"),
|
|
186
|
+
]
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
GalleryEntry(
|
|
190
|
+
"list_page",
|
|
191
|
+
"ListPage: title, bounded scrolling list, and a footer hint",
|
|
192
|
+
Size(40, 9),
|
|
193
|
+
lambda: ListPage(
|
|
194
|
+
[ListItem(f"item-{index}", f"row {index}", f"detail {index}") for index in range(8)],
|
|
195
|
+
title="Select Something",
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
GalleryEntry(
|
|
199
|
+
"table",
|
|
200
|
+
"Table: header rule, wrapping cells, per-column alignment",
|
|
201
|
+
Size(42, 6),
|
|
202
|
+
lambda: Table(
|
|
203
|
+
[Column("Name"), Column("Files", align="right"), Column("State", align="center")],
|
|
204
|
+
[["parser", "12", "done"], ["renderer", "7", "wip"], ["tests", "31", "done"]],
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
GalleryEntry(
|
|
208
|
+
"diff",
|
|
209
|
+
"DiffView: hunk header, line numbers, word-level emphasis",
|
|
210
|
+
Size(58, 9),
|
|
211
|
+
lambda: DiffView.between(DIFF_BEFORE, DIFF_AFTER, context=2),
|
|
212
|
+
),
|
|
213
|
+
GalleryEntry(
|
|
214
|
+
"markdown",
|
|
215
|
+
"MarkdownView: headings, bullets, inline spans, fenced code",
|
|
216
|
+
Size(46, 12),
|
|
217
|
+
lambda: MarkdownView(MARKDOWN_SAMPLE, follow_tail=False),
|
|
218
|
+
),
|
|
219
|
+
GalleryEntry("textarea", "TextArea: prompt, wrapping, and a pre-filled draft", Size(46, 4), _textarea),
|
|
220
|
+
GalleryEntry(
|
|
221
|
+
"completion",
|
|
222
|
+
"CompletionPopup: command column, muted descriptions, banded selection",
|
|
223
|
+
Size(34, 5),
|
|
224
|
+
lambda: CompletionPopup(
|
|
225
|
+
[
|
|
226
|
+
CompletionItem("/clear", description="clear the transcript"),
|
|
227
|
+
CompletionItem("/quit", description="exit"),
|
|
228
|
+
CompletionItem("/theme", description="switch palette"),
|
|
229
|
+
],
|
|
230
|
+
max_height=3,
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
GalleryEntry(
|
|
234
|
+
"dialog",
|
|
235
|
+
"Dialog: bordered body with a row of selectable actions",
|
|
236
|
+
Size(38, 9),
|
|
237
|
+
lambda: Dialog(
|
|
238
|
+
Text("rm -rf build/"),
|
|
239
|
+
title="Run this command?",
|
|
240
|
+
actions=(DialogAction("Run"), DialogAction("Always"), DialogAction("Abort")),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
GalleryEntry(
|
|
244
|
+
"collapsible",
|
|
245
|
+
"Collapsible: header marker plus an expanded body",
|
|
246
|
+
Size(40, 5),
|
|
247
|
+
lambda: Collapsible("Thinking", Text("Checked the renderer, then the keymap."), expanded=True),
|
|
248
|
+
),
|
|
249
|
+
GalleryEntry(
|
|
250
|
+
"toast",
|
|
251
|
+
"Toast: level-coloured notice with a frame",
|
|
252
|
+
Size(32, 3),
|
|
253
|
+
lambda: Toast("saved to disk", level="success"),
|
|
254
|
+
),
|
|
255
|
+
GalleryEntry("tasks", "TaskPanel: one row per task, coloured by state", Size(38, 4), _task_panel),
|
|
256
|
+
GalleryEntry(
|
|
257
|
+
"scroll",
|
|
258
|
+
"ScrollView: virtualized window over already-wrapped lines",
|
|
259
|
+
Size(30, 5),
|
|
260
|
+
lambda: ScrollView(StaticLines([TextLine((Span(line),)) for line in SCROLL_LINES]), follow_tail=False),
|
|
261
|
+
),
|
|
262
|
+
GalleryEntry("layout", "VBox, HBox, Padding, Border, and a centred Overlay", Size(48, 8), _layout_sample),
|
|
263
|
+
GalleryEntry(
|
|
264
|
+
"rich_text",
|
|
265
|
+
"RichText: styled spans, wrapping, and display-column alignment",
|
|
266
|
+
Size(42, 4),
|
|
267
|
+
lambda: RichText(
|
|
268
|
+
[
|
|
269
|
+
TextLine((Span("状态:", Style(foreground=DARK.accent)), Span("运行中", Style(bold=True)))),
|
|
270
|
+
TextLine((Span("中文 and long styled text wrap at cell boundaries.", Style(foreground=DARK.subtle)),)),
|
|
271
|
+
]
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def select_entries(names: Sequence[str] = ()) -> tuple[GalleryEntry, ...]:
|
|
278
|
+
"""Return the entries named by ``names``, or every entry when it is empty.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
names: Component names as written after ``make demo-``; an empty
|
|
282
|
+
sequence means the whole gallery.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: One of the names is unknown; the message lists the valid
|
|
286
|
+
ones so a typo is obvious on the command line.
|
|
287
|
+
"""
|
|
288
|
+
if not names:
|
|
289
|
+
return GALLERY
|
|
290
|
+
known = {entry.name: entry for entry in GALLERY}
|
|
291
|
+
chosen: list[GalleryEntry] = []
|
|
292
|
+
for name in names:
|
|
293
|
+
entry = known.get(name)
|
|
294
|
+
if entry is None:
|
|
295
|
+
options = ", ".join(known)
|
|
296
|
+
raise ValueError(f"Unknown component {name!r}. Available: {options}")
|
|
297
|
+
chosen.append(entry)
|
|
298
|
+
return tuple(chosen)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def render_entry(entry: GalleryEntry, *, theme: Theme = DARK) -> Canvas:
|
|
302
|
+
"""Mount one entry's widget and return its single painted frame.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
entry: Component to render; its factory is called once per invocation.
|
|
306
|
+
theme: Palette the preview is painted with.
|
|
307
|
+
"""
|
|
308
|
+
app = TuiApp(entry.build(), width=entry.size.width, height=entry.size.height, theme=theme)
|
|
309
|
+
app.mount()
|
|
310
|
+
return app.render()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def frame_text(canvas: Canvas, *, color: bool = True, depth: ColorDepth = ColorDepth.TRUECOLOR) -> str:
|
|
314
|
+
"""Return a canvas as printable text, optionally with its colours.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
canvas: Frame to flatten.
|
|
318
|
+
color: Emit SGR sequences per styled run; when False the frame is plain
|
|
319
|
+
text, which is what a pipe or a golden file wants.
|
|
320
|
+
depth: Colour encoding used when ``color`` is True.
|
|
321
|
+
"""
|
|
322
|
+
rows = [row_text(canvas, index, color=color, depth=depth) for index in range(canvas.height)]
|
|
323
|
+
while rows and not rows[-1]:
|
|
324
|
+
rows.pop()
|
|
325
|
+
return "\n".join(rows)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def row_text(canvas: Canvas, index: int, *, color: bool = True, depth: ColorDepth = ColorDepth.TRUECOLOR) -> str:
|
|
329
|
+
"""Return one canvas row, trimmed of trailing padding.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
canvas: Frame that owns the row.
|
|
333
|
+
index: Row to read; out-of-range indexes render as empty.
|
|
334
|
+
color: Wrap each styled run in its SGR sequence.
|
|
335
|
+
depth: Colour encoding used when ``color`` is True.
|
|
336
|
+
"""
|
|
337
|
+
if not 0 <= index < canvas.height:
|
|
338
|
+
return ""
|
|
339
|
+
cells = [cell for cell in canvas.cells[index] if not cell.continuation]
|
|
340
|
+
plain = "".join(cell.character or " " for cell in cells).rstrip()
|
|
341
|
+
if not color or not plain:
|
|
342
|
+
return plain
|
|
343
|
+
parts: list[str] = []
|
|
344
|
+
pending = ""
|
|
345
|
+
style: Style | None = None
|
|
346
|
+
for cell in cells:
|
|
347
|
+
if len(pending) >= len(plain):
|
|
348
|
+
break
|
|
349
|
+
if cell.style != style and pending and style is not None:
|
|
350
|
+
parts.append(encode_style(style, depth) + pending)
|
|
351
|
+
pending = ""
|
|
352
|
+
style = cell.style
|
|
353
|
+
pending += cell.character or " "
|
|
354
|
+
if pending and style is not None:
|
|
355
|
+
parts.append(encode_style(style, depth) + pending)
|
|
356
|
+
# SGR 0 closes each row so a colour cannot leak into the next line.
|
|
357
|
+
return "".join(parts) + "\x1b[0m"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def print_gallery(
|
|
361
|
+
entries: Sequence[GalleryEntry] | None = None,
|
|
362
|
+
*,
|
|
363
|
+
color: bool = True,
|
|
364
|
+
stream: TextIO | None = None,
|
|
365
|
+
theme: Theme = DARK,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Print one caption and one frame per entry.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
entries: Components to print; defaults to the whole gallery.
|
|
371
|
+
color: Paint each frame with its palette.
|
|
372
|
+
stream: Sink to write to; defaults to ``sys.stdout`` at call time so a
|
|
373
|
+
caller that swapped it out (a test, a log file) is honored. A pipe
|
|
374
|
+
usually wants ``color=False``.
|
|
375
|
+
theme: Palette the previews are painted with.
|
|
376
|
+
"""
|
|
377
|
+
sink = sys.stdout if stream is None else stream
|
|
378
|
+
for entry in GALLERY if entries is None else entries:
|
|
379
|
+
print(f"### {entry.name} - {entry.summary}", file=sink)
|
|
380
|
+
print(frame_text(render_entry(entry, theme=theme), color=color), file=sink)
|
|
381
|
+
print(file=sink)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class GalleryRoot(Widget):
|
|
385
|
+
"""Root that hands the whole application rectangle to the browser body."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, body: Widget) -> None:
|
|
388
|
+
"""Keep the body this root exists to place.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
body: The browser's box tree, laid out at the full size of the app.
|
|
392
|
+
"""
|
|
393
|
+
super().__init__()
|
|
394
|
+
self.body = body
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def children(self) -> tuple[Widget, ...]:
|
|
398
|
+
"""Expose the single body widget."""
|
|
399
|
+
return (self.body,)
|
|
400
|
+
|
|
401
|
+
def layout(self, rect: Rect) -> None:
|
|
402
|
+
"""Give the body the full application rectangle."""
|
|
403
|
+
super().layout(rect)
|
|
404
|
+
self.body.layout(rect)
|
|
405
|
+
|
|
406
|
+
def render(self, canvas: Canvas) -> None:
|
|
407
|
+
"""Paint the body into the shared canvas."""
|
|
408
|
+
self.body.render(canvas)
|
|
409
|
+
|
|
410
|
+
def cursor(self) -> Point | None:
|
|
411
|
+
"""Forward the cursor request to the body."""
|
|
412
|
+
return self.body.cursor()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class Spacer(Widget):
|
|
416
|
+
"""Hold a column open so a sibling starts further along the row."""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class GalleryBrowser:
|
|
420
|
+
"""Interactive browser: an index on the left, a live preview on the right.
|
|
421
|
+
|
|
422
|
+
The preview is a screen layer above the index rather than a swapped-in
|
|
423
|
+
child, because pushing a screen is what mounts a fresh widget subtree and
|
|
424
|
+
attaches it to the app. ``_show_preview`` therefore closes the previous
|
|
425
|
+
layer before opening the next one, which keeps exactly one preview mounted
|
|
426
|
+
while the highlight moves.
|
|
427
|
+
|
|
428
|
+
The keyboard stays with the index until Enter is pressed, so the arrow keys
|
|
429
|
+
keep browsing instead of being absorbed by whichever preview happens to be
|
|
430
|
+
focusable. Escape hands the keyboard back.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def __init__(self, entries: Sequence[GalleryEntry] = GALLERY, *, theme: Theme = DARK) -> None:
|
|
434
|
+
"""Build the index, the preview mechanism, and their key bindings.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
entries: Components to offer, in index order.
|
|
438
|
+
theme: Palette shared by the index and every preview.
|
|
439
|
+
"""
|
|
440
|
+
self.entries = tuple(entries)
|
|
441
|
+
self.preview_screen: Screen | None = None
|
|
442
|
+
self.list = ListView(
|
|
443
|
+
[ListItem(entry.name, label=entry.name, description=entry.summary) for entry in self.entries],
|
|
444
|
+
on_select=self._enter_preview,
|
|
445
|
+
on_highlight=self._show_preview,
|
|
446
|
+
)
|
|
447
|
+
self.status = StatusBar(self._status)
|
|
448
|
+
index = VBox(
|
|
449
|
+
[
|
|
450
|
+
Slot(self.list, flex=1),
|
|
451
|
+
Slot(Rule(), size=1),
|
|
452
|
+
Slot(self.status, size=1),
|
|
453
|
+
]
|
|
454
|
+
)
|
|
455
|
+
body = VBox(
|
|
456
|
+
[
|
|
457
|
+
Slot(StatusBar("components", self._hint), size=1),
|
|
458
|
+
Slot(Rule(), size=1),
|
|
459
|
+
# The index owns a fixed column so the preview, which is
|
|
460
|
+
# anchored to the right edge of its own screen, cannot overlap it.
|
|
461
|
+
Slot(HBox([Slot(index, size=SIDEBAR)]), flex=1),
|
|
462
|
+
]
|
|
463
|
+
)
|
|
464
|
+
self.app = TuiApp(GalleryRoot(body), theme=theme)
|
|
465
|
+
self._install_keymap()
|
|
466
|
+
self.app.mount()
|
|
467
|
+
self._show_preview(self.list.current)
|
|
468
|
+
|
|
469
|
+
def run(self) -> None:
|
|
470
|
+
"""Own the terminal until the user quits."""
|
|
471
|
+
from .runner import TerminalRunner
|
|
472
|
+
|
|
473
|
+
asyncio.run(TerminalRunner(self.app).run())
|
|
474
|
+
|
|
475
|
+
def _install_keymap(self) -> None:
|
|
476
|
+
"""Bind the browser keys: Ctrl-C and q quit, Escape steps back."""
|
|
477
|
+
self.app.commands.add("quit", self._quit)
|
|
478
|
+
self.app.commands.add("leave_preview", self._leave_preview)
|
|
479
|
+
self.app.keymap.bind("ctrl_c", "quit", priority="capture")
|
|
480
|
+
# q only quits from the index: inside a preview it belongs to the widget
|
|
481
|
+
# being driven, such as a text area the user is typing into.
|
|
482
|
+
self.app.keymap.bind("q", "quit", priority="capture", when=self._at_index)
|
|
483
|
+
self.app.keymap.bind("escape", "leave_preview", priority="capture", when=self._in_preview)
|
|
484
|
+
|
|
485
|
+
def _at_index(self) -> bool:
|
|
486
|
+
"""Return whether the index itself holds the keyboard."""
|
|
487
|
+
return self.app.focused_widget() is self.list
|
|
488
|
+
|
|
489
|
+
def _in_preview(self) -> bool:
|
|
490
|
+
"""Return whether the keyboard is inside a preview widget."""
|
|
491
|
+
return self.preview_screen is not None and not self._at_index()
|
|
492
|
+
|
|
493
|
+
def _quit(self, event: AnyEvent, host: Host) -> bool:
|
|
494
|
+
"""Leave the gallery."""
|
|
495
|
+
host.exit()
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def _leave_preview(self, event: AnyEvent, host: Host) -> bool:
|
|
499
|
+
"""Hand the keyboard back to the index."""
|
|
500
|
+
self.app.focus(self.list)
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
def _current(self) -> GalleryEntry | None:
|
|
504
|
+
"""Return the entry highlighted in the index."""
|
|
505
|
+
item = self.list.current
|
|
506
|
+
if item is None:
|
|
507
|
+
return None
|
|
508
|
+
return next((entry for entry in self.entries if entry.name == item.value), None)
|
|
509
|
+
|
|
510
|
+
def _status(self) -> str:
|
|
511
|
+
"""Label the previewed component and what it demonstrates."""
|
|
512
|
+
entry = self._current()
|
|
513
|
+
return f" {entry.name}: {entry.summary}" if entry else " components"
|
|
514
|
+
|
|
515
|
+
def _hint(self) -> str:
|
|
516
|
+
"""List the keys worth knowing while the index has focus."""
|
|
517
|
+
return f" up/down browse {SEPARATOR} enter interact {SEPARATOR} esc back {SEPARATOR} q quit "
|
|
518
|
+
|
|
519
|
+
def _show_preview(self, item: ListItem | None) -> None:
|
|
520
|
+
"""Replace the preview layer with the newly highlighted component.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
item: Row the index just highlighted; ignored when it is unknown.
|
|
524
|
+
"""
|
|
525
|
+
entry = next((entry for entry in self.entries if entry.name == item.value), None) if item else None
|
|
526
|
+
if entry is None:
|
|
527
|
+
return
|
|
528
|
+
if self.preview_screen is not None:
|
|
529
|
+
self.app.pop_screen()
|
|
530
|
+
panel = Border(entry.build(), title=f" {entry.name} ")
|
|
531
|
+
overlay = Overlay([OverlaySlot(panel, Anchor(horizontal="end", vertical="center", offset_x=-1))])
|
|
532
|
+
# The spacer keeps the overlay's rectangle to the right of the index, so
|
|
533
|
+
# an anchor at "end" resolves inside the free area rather than over it.
|
|
534
|
+
region = HBox([Slot(Spacer(), size=SIDEBAR), Slot(overlay, flex=1)])
|
|
535
|
+
self.preview_screen = self.app.push_screen(Screen(region, name="preview"))
|
|
536
|
+
# push_screen would hand the keyboard to a focusable preview; browsing
|
|
537
|
+
# stays on the index until Enter is pressed.
|
|
538
|
+
self.app.focus(self.list)
|
|
539
|
+
|
|
540
|
+
def _enter_preview(self, item: ListItem) -> None:
|
|
541
|
+
"""Move the keyboard into the preview so the widget can be driven."""
|
|
542
|
+
if self.preview_screen is not None:
|
|
543
|
+
self.app.focus_next()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
547
|
+
"""Render the requested components, returning the process exit code.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
argv: Arguments without the program name; ``None`` reads ``sys.argv``.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
0 on success, 2 when a requested name is unknown, so a typo after
|
|
554
|
+
``make demo-`` fails loudly instead of printing nothing.
|
|
555
|
+
"""
|
|
556
|
+
parser = argparse.ArgumentParser(prog="make demo", description="Preview the TUI framework widgets.")
|
|
557
|
+
parser.add_argument("names", nargs="*", help="component names to print; omit to browse or print all")
|
|
558
|
+
parser.add_argument("--list", action="store_true", help="list component names and exit")
|
|
559
|
+
parser.add_argument("--no-color", action="store_true", help="print plain text frames")
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"--print", dest="print_all", action="store_true", help="print every component instead of browsing"
|
|
562
|
+
)
|
|
563
|
+
args = parser.parse_args(argv)
|
|
564
|
+
|
|
565
|
+
if args.list:
|
|
566
|
+
for entry in GALLERY:
|
|
567
|
+
print(f"{entry.name:<12} {entry.summary}")
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
entries = select_entries(args.names)
|
|
572
|
+
except ValueError as error:
|
|
573
|
+
print(error, file=sys.stderr)
|
|
574
|
+
return 2
|
|
575
|
+
|
|
576
|
+
# Named components always print: `make demo-list` is the quick "show me this
|
|
577
|
+
# one" path, while the bare command browses when it owns a terminal.
|
|
578
|
+
browsing = not args.names and not args.print_all
|
|
579
|
+
if browsing and sys.stdin.isatty() and sys.stdout.isatty():
|
|
580
|
+
GalleryBrowser(entries).run()
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
print_gallery(entries, color=not args.no_color and sys.stdout.isatty())
|
|
584
|
+
return 0
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
if __name__ == "__main__":
|
|
588
|
+
raise SystemExit(main())
|
zettcode/tui/glyphs.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The glyphs the interface is written in.
|
|
2
|
+
|
|
3
|
+
One home for every non-ASCII character that carries meaning, so a piece of
|
|
4
|
+
punctuation can be changed once and a reader can look up what a shape is for.
|
|
5
|
+
A name says what the glyph *means*; the character is in the comment beside it.
|
|
6
|
+
|
|
7
|
+
Art a single widget owns stays with that widget: the welcome mark's box, the
|
|
8
|
+
plan panel's frame, a Markdown table's rules, the gallery's samples. This module
|
|
9
|
+
is the vocabulary, not the drawing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
#: Between the parts of a status line, footer, or announcement: "\u00b7".
|
|
15
|
+
SEPARATOR = "\u00b7"
|
|
16
|
+
|
|
17
|
+
#: Where text was cut: "\u2026", one cell where "..." would take three.
|
|
18
|
+
ELLIPSIS = "\u2026"
|
|
19
|
+
|
|
20
|
+
#: The composer's prompt, and the mark in front of a user message: "\u203a".
|
|
21
|
+
PROMPT = "\u203a"
|
|
22
|
+
|
|
23
|
+
#: In front of the highlighted row of a list, of the step a plan is running,
|
|
24
|
+
#: and of a closed disclosure: "\u25b8".
|
|
25
|
+
MARKER = "\u25b8"
|
|
26
|
+
|
|
27
|
+
#: In front of an open disclosure, its closed form being :data:`MARKER`:
|
|
28
|
+
#: "\u25be".
|
|
29
|
+
EXPANDED = "\u25be"
|
|
30
|
+
|
|
31
|
+
#: In front of a plan step that has not started yet, where the dot stands for
|
|
32
|
+
#: the work rather than separating two things: "\u00b7".
|
|
33
|
+
PENDING = "\u00b7"
|
|
34
|
+
|
|
35
|
+
#: In front of a step that finished: "\u2713".
|
|
36
|
+
DONE = "\u2713"
|
|
37
|
+
|
|
38
|
+
#: In front of a row that failed: "\u00d7".
|
|
39
|
+
FAILED = "\u00d7"
|
|
40
|
+
|
|
41
|
+
#: In front of a step that was skipped: "\u2013", an en dash.
|
|
42
|
+
SKIPPED = "\u2013"
|
|
43
|
+
|
|
44
|
+
#: The idle status dot, and the fallback marker of a row whose state is
|
|
45
|
+
#: unknown: "\u25cf".
|
|
46
|
+
STATUS = "\u25cf"
|
|
47
|
+
|
|
48
|
+
#: Tokens the request spent and tokens the model generated, as in the status
|
|
49
|
+
#: line's counters: "\u2191" and "\u2193". The transcript's "back to bottom"
|
|
50
|
+
#: badge uses the same down arrow.
|
|
51
|
+
ARROW_UP = "\u2191"
|
|
52
|
+
ARROW_DOWN = "\u2193"
|
|
53
|
+
|
|
54
|
+
#: In front of the application name in the header: "\u25c8".
|
|
55
|
+
HEADER = "\u25c8"
|
|
56
|
+
|
|
57
|
+
#: In front of a message queued to steer the running request: "\u21b3".
|
|
58
|
+
STEERING = "\u21b3"
|
|
59
|
+
|
|
60
|
+
#: What :class:`~zettcode.tui.widgets.text.Rule` repeats into a line: "\u2500".
|
|
61
|
+
RULE = "\u2500"
|
|
62
|
+
|
|
63
|
+
#: A determinate bar's filled and empty cells: "\u2588" and "\u2591".
|
|
64
|
+
BAR_FULL = "\u2588"
|
|
65
|
+
BAR_EMPTY = "\u2591"
|
|
66
|
+
|
|
67
|
+
#: The braille frames :class:`~zettcode.tui.widgets.progress.Spinner` cycles
|
|
68
|
+
#: through, from an empty to a nearly full run.
|
|
69
|
+
SPINNER = ("\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f")
|
|
70
|
+
|
|
71
|
+
#: The two states every running marker blinks between, in step: "\u2726" and
|
|
72
|
+
#: the plain dot.
|
|
73
|
+
RUNNING = ("\u2726", "\u00b7")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Terminal input decoding and event delivery."""
|
|
2
|
+
|
|
3
|
+
from .bridge import translate
|
|
4
|
+
from .decoder import InputDecoder
|
|
5
|
+
from .events import EventType, InputEvent, MouseAction
|
|
6
|
+
from .keys import CONTROL_KEYS, KEY_SEQUENCES
|
|
7
|
+
from .reader import AsyncInput
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CONTROL_KEYS",
|
|
11
|
+
"KEY_SEQUENCES",
|
|
12
|
+
"AsyncInput",
|
|
13
|
+
"EventType",
|
|
14
|
+
"InputDecoder",
|
|
15
|
+
"InputEvent",
|
|
16
|
+
"MouseAction",
|
|
17
|
+
"translate",
|
|
18
|
+
]
|