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,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)