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,165 @@
1
+ """A framed modal body with a row of selectable actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from ..core.events import KeyEvent
9
+ from ..core.geometry import Constraints, Rect, Size
10
+ from ..core.host import Host
11
+ from ..core.widget import Widget
12
+ from ..layout import Border, Slot, VBox
13
+ from ..render import Canvas, Style
14
+ from ..render.text import display_width
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class DialogAction:
19
+ """One button in a dialog.
20
+
21
+ Attributes:
22
+ label: Text drawn on the button.
23
+ run: Called when the action is activated; ``None`` renders a button that
24
+ cannot be committed.
25
+ """
26
+
27
+ label: str
28
+ run: Callable[[], None] | None = None
29
+
30
+
31
+ class _ActionRow(Widget):
32
+ """Internal row that paints the dialog buttons."""
33
+
34
+ def __init__(self, dialog: Dialog) -> None:
35
+ """Bind the row to the dialog that owns the actions."""
36
+ super().__init__()
37
+ self.dialog = dialog
38
+
39
+ def measure(self, constraints: Constraints) -> Size:
40
+ """Claim a minimal cell; the parent box supplies the real width."""
41
+ return constraints.constrain(Size(1, 1))
42
+
43
+ def render(self, canvas: Canvas) -> None:
44
+ """Paint the actions along the last row of the assigned rectangle."""
45
+ if self.rect.empty:
46
+ return
47
+ theme = self.theme
48
+ y = self.rect.y + self.rect.height - 1
49
+ x = self.rect.x
50
+ for index, action in enumerate(self.dialog.actions):
51
+ if x >= self.rect.x + self.rect.width:
52
+ break
53
+ label = f" {action.label} "
54
+ selected = index == self.dialog.action_index
55
+ style = (
56
+ Style(foreground=theme.surface, background=theme.accent, bold=True)
57
+ if selected
58
+ else Style(foreground=theme.muted)
59
+ )
60
+ canvas.draw_text(x, y, label, style, max_width=self.rect.x + self.rect.width - x)
61
+ x += display_width(label) + 1
62
+
63
+
64
+ class Dialog(Widget):
65
+ """A bordered body plus actions, meant to live on a modal screen.
66
+
67
+ Shape::
68
+
69
+ +-- Run this command? --------+ <- frame, title embedded in the top edge
70
+ |rm -rf build/ | <- body, takes the flexible rows
71
+ | |
72
+ | Run Always Abort | <- actions on the last row, one highlighted
73
+ +-----------------------------+
74
+
75
+ The frame uses box-drawing glyphs and clears its own rectangle before
76
+ drawing, so a dialog over another screen is opaque. Left and Right move the
77
+ highlight, Enter runs it, Escape cancels when ``on_cancel`` is given.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ body: Widget | None = None,
83
+ *,
84
+ title: str = "",
85
+ actions: Sequence[DialogAction] = (),
86
+ on_cancel: Callable[[], None] | None = None,
87
+ ) -> None:
88
+ """Wrap the body and the action row in a border, with or without a body.
89
+
90
+ Args:
91
+ body: Content above the buttons; ``None`` leaves a dialog of pure
92
+ actions, which is still laid out with the same frame.
93
+ title: Drawn into the top border when the dialog is wide enough.
94
+ actions: Buttons in focus order; the first one starts highlighted.
95
+ on_cancel: Called on Escape when one is provided.
96
+ """
97
+ super().__init__()
98
+ self.body = body
99
+ self.actions = tuple(actions)
100
+ self.action_index = 0
101
+ self.on_cancel = on_cancel
102
+ row = _ActionRow(self)
103
+ inner = VBox([Slot(body, flex=1), Slot(row, size=1)]) if body is not None else VBox([Slot(row, flex=1)])
104
+ self.frame = Border(inner, title=title)
105
+
106
+ @property
107
+ def focusable(self) -> bool:
108
+ """Take focus so the arrow keys and Enter drive the actions."""
109
+ return True
110
+
111
+ @property
112
+ def children(self) -> tuple[Widget, ...]:
113
+ """Expose the bordered frame."""
114
+ return (self.frame,)
115
+
116
+ @property
117
+ def current_action(self) -> DialogAction | None:
118
+ """Return the highlighted action, or None when there are no actions."""
119
+ if 0 <= self.action_index < len(self.actions):
120
+ return self.actions[self.action_index]
121
+ return None
122
+
123
+ def activate(self) -> bool:
124
+ """Run the selected action, if it has a handler."""
125
+ action = self.current_action
126
+ if action is None or action.run is None:
127
+ return False
128
+ action.run()
129
+ return True
130
+
131
+ def measure(self, constraints: Constraints) -> Size:
132
+ """Measure the frame, which measures the body in turn."""
133
+ return self.frame.measure(constraints)
134
+
135
+ def layout(self, rect: Rect) -> None:
136
+ """Give the frame the whole assigned rectangle."""
137
+ super().layout(rect)
138
+ self.frame.layout(rect)
139
+
140
+ def render(self, canvas: Canvas) -> None:
141
+ """Paint the frame and everything inside it."""
142
+ self.frame.render(canvas)
143
+
144
+ def handle(self, event, host: Host) -> bool:
145
+ """Move between actions, run one, or cancel the dialog."""
146
+ if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
147
+ return False
148
+ match event.key:
149
+ case "left" | "backtab":
150
+ self._move(-1)
151
+ case "right" | "tab":
152
+ self._move(1)
153
+ case "enter":
154
+ return self.activate()
155
+ case "escape" if self.on_cancel is not None:
156
+ self.on_cancel()
157
+ return True
158
+ case _:
159
+ return False
160
+ return True
161
+
162
+ def _move(self, amount: int) -> None:
163
+ """Move the highlight, wrapping at both ends."""
164
+ if self.actions:
165
+ self.action_index = (self.action_index + amount) % len(self.actions)
@@ -0,0 +1,236 @@
1
+ """Unified diff parsing with word-level emphasis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, replace
7
+ from difflib import SequenceMatcher
8
+ from difflib import unified_diff as _unified_diff
9
+
10
+ from ..core.geometry import Constraints, Size
11
+ from ..core.widget import Widget
12
+ from ..render import Canvas, Style
13
+ from ..render.text import display_width
14
+
15
+ HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
16
+ TOKEN = re.compile(r"\w+|\s+|[^\w\s]")
17
+ META_PREFIXES = ("diff ", "index ", "--- ", "+++ ", "\\ No newline", "old mode", "new mode", "similarity index")
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class DiffSegment:
22
+ """One run of a diff line, marked when it is what actually changed."""
23
+
24
+ text: str
25
+ emphasis: bool = False
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class DiffLine:
30
+ """One rendered diff row in unified-diff order."""
31
+
32
+ kind: str
33
+ text: str
34
+ segments: tuple[DiffSegment, ...] = ()
35
+ old_number: int | None = None
36
+ new_number: int | None = None
37
+
38
+
39
+ def word_diff(old: str, new: str) -> tuple[tuple[DiffSegment, ...], tuple[DiffSegment, ...]]:
40
+ """Return both sides tokenized, marking the tokens that differ.
41
+
42
+ Args:
43
+ old: Text of the removed line.
44
+ new: Text of the added line it is paired with.
45
+ """
46
+ old_tokens = TOKEN.findall(old)
47
+ new_tokens = TOKEN.findall(new)
48
+ matcher = SequenceMatcher(None, old_tokens, new_tokens, autojunk=False)
49
+ old_segments: list[DiffSegment] = []
50
+ new_segments: list[DiffSegment] = []
51
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
52
+ changed = tag != "equal"
53
+ old_segments.append(DiffSegment("".join(old_tokens[i1:i2]), changed))
54
+ new_segments.append(DiffSegment("".join(new_tokens[j1:j2]), changed))
55
+ return _merge(old_segments), _merge(new_segments)
56
+
57
+
58
+ def parse_unified(text: str) -> list[DiffLine]:
59
+ """Parse unified-diff text, pairing removals with additions by position."""
60
+ lines: list[DiffLine] = []
61
+ removals: list[DiffLine] = []
62
+ additions: list[DiffLine] = []
63
+ old_number: int | None = None
64
+ new_number: int | None = None
65
+
66
+ def flush() -> None:
67
+ """Pair buffered removals with additions, then emit both blocks."""
68
+ nonlocal removals, additions
69
+ if not removals and not additions:
70
+ return
71
+ for index in range(min(len(removals), len(additions))):
72
+ old_segments, new_segments = word_diff(removals[index].text, additions[index].text)
73
+ removals[index] = replace(removals[index], segments=old_segments)
74
+ additions[index] = replace(additions[index], segments=new_segments)
75
+ for line in removals:
76
+ lines.append(line if line.segments else replace(line, segments=(DiffSegment(line.text, True),)))
77
+ for line in additions:
78
+ lines.append(line if line.segments else replace(line, segments=(DiffSegment(line.text, True),)))
79
+ removals, additions = [], []
80
+
81
+ for raw in text.splitlines():
82
+ if raw.startswith(META_PREFIXES):
83
+ flush()
84
+ lines.append(DiffLine("meta", raw))
85
+ continue
86
+ hunk = HUNK.match(raw)
87
+ if hunk:
88
+ flush()
89
+ old_number = int(hunk.group(1))
90
+ new_number = int(hunk.group(3))
91
+ lines.append(DiffLine("hunk", raw))
92
+ continue
93
+ if raw.startswith("+"):
94
+ additions.append(DiffLine("add", raw[1:], new_number=new_number))
95
+ new_number = None if new_number is None else new_number + 1
96
+ continue
97
+ if raw.startswith("-"):
98
+ removals.append(DiffLine("remove", raw[1:], old_number=old_number))
99
+ old_number = None if old_number is None else old_number + 1
100
+ continue
101
+ flush()
102
+ content = raw[1:] if raw.startswith(" ") else raw
103
+ lines.append(DiffLine("context", content, old_number=old_number, new_number=new_number))
104
+ old_number = None if old_number is None else old_number + 1
105
+ new_number = None if new_number is None else new_number + 1
106
+ flush()
107
+ return lines
108
+
109
+
110
+ def build_unified(
111
+ old: str,
112
+ new: str,
113
+ *,
114
+ fromfile: str = "a",
115
+ tofile: str = "b",
116
+ context: int = 3,
117
+ ) -> list[DiffLine]:
118
+ """Diff two texts and return parsed diff rows.
119
+
120
+ Args:
121
+ old: Original text; trailing newlines are ignored.
122
+ new: Replacement text.
123
+ fromfile: Label of the original side in the ``---`` header line.
124
+ tofile: Label of the replacement side in the ``+++`` header line.
125
+ context: Unchanged lines kept around each hunk.
126
+ """
127
+ # difflib appends its own terminator to header lines, so the terminator has
128
+ # to be suppressed before joining: a stray blank line would otherwise parse
129
+ # as a context row and shift every following line number.
130
+ text = "\n".join(_unified_diff(old.splitlines(), new.splitlines(), fromfile, tofile, n=context, lineterm=""))
131
+ return parse_unified(text)
132
+
133
+
134
+ class DiffView(Widget):
135
+ """Paint unified-diff rows with a gutter and word-level emphasis.
136
+
137
+ Shape::
138
+
139
+ --- a
140
+ +++ b
141
+ @@ -1,5 +1,2 @@ <- hunk headers, dim
142
+ 1 1 def total(items): <- old and new line numbers
143
+ 2 - value = 0 <- removed
144
+ 2 + return sum(...) <- added
145
+
146
+ The gutter is reserved only when the diff carries line numbers. Removed and
147
+ added lines are paired by position, and only the tokens that actually differ
148
+ get the selection colour, which is what makes a one-word change readable.
149
+ """
150
+
151
+ def __init__(self, lines: tuple[DiffLine, ...] | list[DiffLine] = ()) -> None:
152
+ """Store the diff rows; the widget itself only paints them.
153
+
154
+ Args:
155
+ lines: Rows in unified-diff order, as ``parse_unified`` returns them.
156
+ """
157
+ super().__init__()
158
+ self.lines = tuple(lines)
159
+
160
+ @classmethod
161
+ def from_text(cls, text: str) -> DiffView:
162
+ """Build a view from unified-diff text."""
163
+ return cls(parse_unified(text))
164
+
165
+ @classmethod
166
+ def between(cls, old: str, new: str, *, context: int = 3) -> DiffView:
167
+ """Diff two texts with difflib and wrap the result.
168
+
169
+ Args:
170
+ old: Original text; trailing newlines are ignored.
171
+ new: Replacement text.
172
+ context: Unchanged lines kept around each hunk.
173
+ """
174
+ return cls(build_unified(old, new, context=context))
175
+
176
+ @property
177
+ def gutter(self) -> int:
178
+ """Reserve line numbers only when the diff actually carries them."""
179
+ if any(line.old_number is not None or line.new_number is not None for line in self.lines):
180
+ return 11
181
+ return 0
182
+
183
+ def measure(self, constraints: Constraints) -> Size:
184
+ """Ask for the widest row plus the gutter and the sign column."""
185
+ widest = max((display_width(line.text) + self.gutter + 1 for line in self.lines), default=0)
186
+ return constraints.constrain(Size(widest, len(self.lines)))
187
+
188
+ def render(self, canvas: Canvas) -> None:
189
+ """Paint each row with its line numbers, sign, and emphasised segments."""
190
+ if self.rect.empty:
191
+ return
192
+ theme = self.theme
193
+ styles = {
194
+ "add": Style(foreground=theme.accent, bold=True),
195
+ "remove": Style(foreground=theme.error, bold=True),
196
+ "hunk": Style(foreground=theme.accent_bright, dim=True),
197
+ "meta": Style(foreground=theme.muted, dim=True),
198
+ "context": Style(foreground=theme.subtle),
199
+ }
200
+ gutter = self.gutter
201
+ limit = self.rect.x + self.rect.width
202
+ for row, line in enumerate(self.lines[: self.rect.height]):
203
+ y = self.rect.y + row
204
+ x = self.rect.x
205
+ style = styles.get(line.kind, styles["context"])
206
+ if gutter:
207
+ old = "" if line.old_number is None else str(line.old_number)
208
+ new = "" if line.new_number is None else str(line.new_number)
209
+ x += canvas.draw_text(
210
+ x,
211
+ y,
212
+ f"{old:>4} {new:>4} ",
213
+ Style(foreground=theme.muted, dim=True),
214
+ max_width=gutter,
215
+ )
216
+ sign = {"add": "+", "remove": "-", "context": " "}.get(line.kind, " ")
217
+ x += canvas.draw_text(x, y, sign, style, max_width=max(0, limit - x))
218
+ segments = line.segments or (DiffSegment(line.text),)
219
+ for segment in segments:
220
+ if x >= limit:
221
+ break
222
+ segment_style = replace(style, background=theme.selection, bold=True) if segment.emphasis else style
223
+ x += canvas.draw_text(x, y, segment.text, segment_style, max_width=limit - x)
224
+
225
+
226
+ def _merge(segments: list[DiffSegment]) -> tuple[DiffSegment, ...]:
227
+ """Join adjacent runs that share an emphasis flag."""
228
+ merged: list[DiffSegment] = []
229
+ for segment in segments:
230
+ if not segment.text:
231
+ continue
232
+ if merged and merged[-1].emphasis == segment.emphasis:
233
+ merged[-1] = DiffSegment(merged[-1].text + segment.text, segment.emphasis)
234
+ else:
235
+ merged.append(segment)
236
+ return tuple(merged)
@@ -0,0 +1,293 @@
1
+ """Selectable list with keyboard navigation and a scrolling window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, field
7
+
8
+ from zettcode.tui import MARKER
9
+
10
+ from ..core.events import KeyEvent, MouseAction, MouseEvent
11
+ from ..core.geometry import Constraints, Size
12
+ from ..core.host import Host
13
+ from ..core.theme import Theme
14
+ from ..core.widget import Widget
15
+ from ..render import Canvas, Style, truncate
16
+ from ..render.text import display_width
17
+
18
+ #: Text and the colour to draw it in; ``None`` leaves the colour the row would have had.
19
+ Part = tuple[str, str | None]
20
+
21
+ #: Pieces of a line in the active theme's colours, which only the theme can say.
22
+ Parts = Callable[[Theme], Sequence[Part]]
23
+
24
+
25
+ def draw_parts(canvas: Canvas, x: int, y: int, parts: Sequence[Part], style: Style, *, max_width: int) -> int:
26
+ """Draw coloured pieces one after another and return the columns used.
27
+
28
+ Args:
29
+ canvas: Where to paint.
30
+ x: Column of the first piece.
31
+ y: Row.
32
+ parts: Text and foreground colour of each piece, in order; a ``None`` colour keeps ``style``'s.
33
+ style: Everything but the foreground: background, weight.
34
+ max_width: Column budget for all the pieces together; the one that does not fit is cut, the rest dropped.
35
+ """
36
+ used = 0
37
+ for text, colour in parts:
38
+ room = max_width - used
39
+ if room <= 0:
40
+ break
41
+ piece = style if colour is None else Style(foreground=colour, background=style.background, bold=style.bold)
42
+ used += canvas.draw_text(x + used, y, truncate(text, room), piece, max_width=room)
43
+ return used
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class ListItem:
48
+ """One row of a ListView.
49
+
50
+ Attributes:
51
+ value: Opaque payload handed back to the callbacks.
52
+ label: Left-hand text of the row.
53
+ description: Secondary text drawn after the label when space allows.
54
+ disabled: Skipped by navigation and refused by ``activate``.
55
+ parts: The same description in colours. It must read as ``description`` when its pieces are joined, which
56
+ is what sizes the column; ``None`` draws ``description`` in the muted colour.
57
+ """
58
+
59
+ value: object = None
60
+ label: str = ""
61
+ description: str = ""
62
+ disabled: bool = False
63
+ parts: Parts | None = field(default=None, compare=False)
64
+
65
+
66
+ class ListView(Widget):
67
+ """Show rows and let one of them be selected.
68
+
69
+ The window is an absolute row index so the list never rebuilds itself, and
70
+ the selection is always kept inside the visible window.
71
+
72
+ Shape::
73
+
74
+ > alpha first <- selected: marker, bold, selection band
75
+ beta second <- descriptions share one column
76
+ gamma <- disabled: dim, no marker, refuses Enter
77
+ delta fourth
78
+
79
+ Only ``rect.height`` rows are drawn, starting at ``top``; the marker is part
80
+ of the label (U+25B8 selected, two spaces otherwise) and the muted
81
+ description keeps the row's background so the selection band stays
82
+ continuous. Descriptions start in one column, the width of the widest label
83
+ plus a gap, measured in cells so a CJK label counts double; when the row is
84
+ too narrow for that column the description falls back to sitting one cell
85
+ after its own label, and a label with a description is truncated to the
86
+ column so the two can never collide.
87
+ """
88
+
89
+ MARKERS = (f"{MARKER} ", " ")
90
+ #: Cells the marker column occupies, and the gap before the description column.
91
+ MARKER_WIDTH = 2
92
+ COLUMN_GAP = 2
93
+
94
+ def __init__(
95
+ self,
96
+ items: Sequence[ListItem] = (),
97
+ *,
98
+ on_select: Callable[[ListItem], None] | None = None,
99
+ on_highlight: Callable[[ListItem], None] | None = None,
100
+ wrap: bool = True,
101
+ band: bool = False,
102
+ ) -> None:
103
+ """Configure the rows, the callbacks, and whether the selection wraps.
104
+
105
+ Args:
106
+ items: Initial rows; ``set_items`` replaces them wholesale.
107
+ on_select: Called with the row when Enter or ``activate`` commits it.
108
+ on_highlight: Called only when the selection actually moves, which
109
+ makes it safe to use for previewing the highlighted row.
110
+ wrap: Wrap from the last row to the first and back.
111
+ band: Fill the entire highlighted row, not just the label.
112
+ """
113
+ super().__init__()
114
+ self._items = tuple(items)
115
+ self.on_select = on_select
116
+ self.on_highlight = on_highlight
117
+ self.wrap = wrap
118
+ self.band = band
119
+ self.selected = 0 if self._items else -1
120
+ self.top = 0
121
+
122
+ @property
123
+ def focusable(self) -> bool:
124
+ """Let Tab land on the list so the arrow keys work without a click."""
125
+ return True
126
+
127
+ @property
128
+ def items(self) -> tuple[ListItem, ...]:
129
+ """Return the rows currently shown."""
130
+ return self._items
131
+
132
+ def set_items(self, items: Sequence[ListItem]) -> None:
133
+ """Replace the rows and reset the selection to the top."""
134
+ self._items = tuple(items)
135
+ self.selected = 0 if self._items else -1
136
+ self.top = 0
137
+
138
+ @property
139
+ def current(self) -> ListItem | None:
140
+ """Return the selected row, or None when the list is empty."""
141
+ if 0 <= self.selected < len(self._items):
142
+ return self._items[self.selected]
143
+ return None
144
+
145
+ def select(self, index: int, *, notify: bool = True) -> None:
146
+ """Move the selection to a row, skipping disabled entries.
147
+
148
+ Args:
149
+ index: Target row, clamped to the list; a disabled row is refused.
150
+ notify: Call ``on_highlight`` when the row actually changes. Pass
151
+ False for programmatic resets that should stay silent.
152
+ """
153
+ if not self._items:
154
+ self.selected = -1
155
+ return
156
+ index = min(max(0, index), len(self._items) - 1)
157
+ if self._items[index].disabled:
158
+ return
159
+ changed = index != self.selected
160
+ self.selected = index
161
+ if changed and notify and self.on_highlight is not None:
162
+ self.on_highlight(self._items[index])
163
+
164
+ def move(self, amount: int) -> None:
165
+ """Move the selection, wrapping when configured to."""
166
+ if not self._items:
167
+ return
168
+ count = len(self._items)
169
+ index = self.selected + amount
170
+ if self.wrap:
171
+ index %= count
172
+ else:
173
+ index = min(max(0, index), count - 1)
174
+ step = 1 if amount >= 0 else -1
175
+ for offset in range(count):
176
+ candidate = (index + offset * step) % count
177
+ if not self._items[candidate].disabled:
178
+ self.select(candidate)
179
+ return
180
+
181
+ def activate(self) -> bool:
182
+ """Fire the select callback for the current row."""
183
+ item = self.current
184
+ if item is None or item.disabled or self.on_select is None:
185
+ return False
186
+ self.on_select(item)
187
+ return True
188
+
189
+ def measure(self, constraints: Constraints) -> Size:
190
+ """Ask for the label column, the gap, the widest description, and a row each."""
191
+ column = max((display_width(item.label) for item in self._items), default=0)
192
+ description = max((display_width(item.description) for item in self._items), default=0)
193
+ width = self.MARKER_WIDTH + column
194
+ if description:
195
+ width += self.COLUMN_GAP + description
196
+ return constraints.constrain(Size(width, len(self._items)))
197
+
198
+ def render(self, canvas: Canvas) -> None:
199
+ """Scroll the selection into view, then paint the visible rows."""
200
+ if not self._items or self.rect.height <= 0:
201
+ return
202
+ self._ensure_visible()
203
+ theme = self.theme
204
+ # One column for every label, so descriptions line up down the list
205
+ # instead of starting wherever each label happens to end.
206
+ column = max((display_width(item.label) for item in self._items), default=0)
207
+ for row in range(self.rect.height):
208
+ index = self.top + row
209
+ if index >= len(self._items):
210
+ break
211
+ item = self._items[index]
212
+ selected = index == self.selected
213
+ marker = self.MARKERS[0] if selected else self.MARKERS[1]
214
+ if item.disabled:
215
+ style = Style(foreground=theme.muted, dim=True)
216
+ elif selected:
217
+ style = Style(foreground=theme.text, background=theme.selection, bold=True)
218
+ else:
219
+ style = Style(foreground=theme.text, background=theme.surface_alt if self.band else None)
220
+ if selected and self.band:
221
+ canvas.fill(self.rect.x, self.rect.y + row, self.rect.width, 1, style)
222
+ aligned = self.MARKER_WIDTH + column + self.COLUMN_GAP
223
+ room = self.rect.width - aligned
224
+ # Without room for a second column, the description falls back to
225
+ # one space after the label, as it did before the column existed.
226
+ inline = display_width(marker + item.label) + 1
227
+ start = aligned if room > 3 else inline
228
+ limit = self.rect.width if not item.description else max(0, start - 1)
229
+ label = truncate(marker + item.label, limit)
230
+ canvas.draw_text(self.rect.x, self.rect.y + row, label, style, max_width=self.rect.width)
231
+ if item.description:
232
+ # Columns, not code points: a label of CJK characters is twice
233
+ # as wide as it is long, and counting its length would pull the
234
+ # description left by one column per wide glyph.
235
+ room = self.rect.width - start
236
+ if room > 3:
237
+ quiet = Style(foreground=theme.muted, background=style.background)
238
+ if item.parts is None or item.disabled:
239
+ canvas.draw_text(
240
+ self.rect.x + start,
241
+ self.rect.y + row,
242
+ truncate(item.description, room),
243
+ quiet,
244
+ max_width=room,
245
+ )
246
+ else:
247
+ draw_parts(
248
+ canvas,
249
+ self.rect.x + start,
250
+ self.rect.y + row,
251
+ item.parts(theme),
252
+ Style(background=style.background, bold=False),
253
+ max_width=room,
254
+ )
255
+
256
+ def handle(self, event, host: Host) -> bool:
257
+ """Select the clicked row, or move with the arrow and page keys."""
258
+ if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
259
+ if event.action is MouseAction.DOWN:
260
+ self.select(self.top + event.y - self.rect.y)
261
+ host.focus(self)
262
+ return True
263
+ return False
264
+ if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
265
+ return False
266
+ page = max(1, self.rect.height - 1)
267
+ match event.key:
268
+ case "up":
269
+ self.move(-1)
270
+ case "down":
271
+ self.move(1)
272
+ case "page_up":
273
+ self.move(-page)
274
+ case "page_down":
275
+ self.move(page)
276
+ case "home":
277
+ self.select(0)
278
+ case "end":
279
+ self.select(len(self._items) - 1)
280
+ case "enter":
281
+ return self.activate()
282
+ case _:
283
+ return False
284
+ return True
285
+
286
+ def _ensure_visible(self) -> None:
287
+ """Scroll the window so the selected row stays on screen."""
288
+ height = max(1, self.rect.height)
289
+ if self.selected < self.top:
290
+ self.top = self.selected
291
+ elif self.selected >= self.top + height:
292
+ self.top = self.selected - height + 1
293
+ self.top = min(max(0, self.top), max(0, len(self._items) - height))