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,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
+ ]