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,211 @@
1
+ """Completion candidates shown next to an editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
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
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CompletionItem:
17
+ """One candidate: the text to insert plus how to present it.
18
+
19
+ Attributes:
20
+ value: Text inserted into the editor when the candidate is chosen.
21
+ label: Row text; falls back to ``value`` when empty.
22
+ description: Muted text shown after the label when space allows.
23
+ type: Optional command owner displayed in a right-aligned column.
24
+ final: Choosing the candidate completes the draft, so Enter inserts it and sends the draft
25
+ instead of only inserting it (the word after a command that is all the command needs).
26
+ current: The candidate stands for what is in force now: it is drawn with a mark, and the menu
27
+ opens with it highlighted.
28
+ """
29
+
30
+ value: str
31
+ label: str = ""
32
+ description: str = ""
33
+ type: str = ""
34
+ final: bool = False
35
+ current: bool = False
36
+
37
+ @property
38
+ def display(self) -> str:
39
+ """Return the text to show, preferring the label over the inserted value."""
40
+ return self.label or self.value
41
+
42
+
43
+ class Completer(ABC):
44
+ """Return candidates for the token under the cursor.
45
+
46
+ Subclass this and implement ``__call__``; the editor stores one and calls it
47
+ with the draft and the cursor position on every keystroke.
48
+ """
49
+
50
+ @abstractmethod
51
+ def __call__(self, text: str, position: int) -> Sequence[CompletionItem]:
52
+ """Return the candidates for the token ending at ``position``."""
53
+
54
+
55
+ class CompletionPopup(Widget):
56
+ """A borderless, scrolling list of completion candidates.
57
+
58
+ Each row is a command column followed by a muted description, and the
59
+ selected row is painted as a full-width band so the choice reads at a
60
+ glance. The popup never takes focus: the editor keeps the cursor and drives
61
+ the selection, so Tab and Enter still belong to the text being typed.
62
+
63
+ Shape::
64
+
65
+ /clear clear the transcript <- selected: band across the whole row
66
+ /quit exit <- command column, muted description
67
+ /theme switch palette
68
+ ^
69
+ descriptions start here, at 1 + longest command + 2
70
+
71
+ The command column is as wide as the longest candidate, so the descriptions
72
+ never shift as the selection moves; the band is a ``fill`` of the row, not
73
+ just a styled label.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ items: Sequence[CompletionItem] = (),
79
+ *,
80
+ selected: int = 0,
81
+ max_height: int = 8,
82
+ ) -> None:
83
+ """Build the popup with an optional initial selection.
84
+
85
+ Args:
86
+ items: Candidates to show; the popup hides itself while empty.
87
+ selected: Index highlighted first, clamped into range.
88
+ max_height: Most candidate rows to show; the popup is exactly that
89
+ tall, with no frame around it.
90
+ """
91
+ super().__init__()
92
+ self.max_height = max(1, max_height)
93
+ self.items: tuple[CompletionItem, ...] = ()
94
+ self.selected = 0
95
+ self.top = 0
96
+ self.set_items(items, selected=selected)
97
+
98
+ def set_items(self, items: Sequence[CompletionItem], *, selected: int | None = None) -> None:
99
+ """Replace the candidates, clamping the selection into range.
100
+
101
+ Args:
102
+ items: New candidate list.
103
+ selected: Index to highlight; ``None`` keeps the current index, which
104
+ is what makes cycling through a refreshed list stable.
105
+ """
106
+ self.items = tuple(items)
107
+ if not self.items:
108
+ self.selected = 0
109
+ self.top = 0
110
+ return
111
+ self.selected = min(max(0, self.selected if selected is None else selected), len(self.items) - 1)
112
+
113
+ def move(self, amount: int) -> None:
114
+ """Move the highlight, wrapping at both ends."""
115
+ if self.items:
116
+ self.selected = (self.selected + amount) % len(self.items)
117
+
118
+ @property
119
+ def current(self) -> CompletionItem | None:
120
+ """Return the highlighted candidate, or None when the list is empty."""
121
+ return self.items[self.selected] if 0 <= self.selected < len(self.items) else None
122
+
123
+ @property
124
+ def visible_height(self) -> int:
125
+ """Return how many candidate rows the popup shows at most."""
126
+ return min(len(self.items), self.max_height)
127
+
128
+ @property
129
+ def visible(self) -> bool:
130
+ """Return whether the popup has anything to show."""
131
+ return bool(self.items)
132
+
133
+ @property
134
+ def name_width(self) -> int:
135
+ """Return the columns reserved for the command column."""
136
+ return max((display_width(item.display) for item in self.items), default=0)
137
+
138
+ def preferred_size(self) -> Size:
139
+ """Size the popup to its two columns, capped at ``max_height`` rows.
140
+
141
+ The width is only a hint: a caller that hands the popup a wider slot,
142
+ such as a full-width row above a composer, gets the whole span painted.
143
+ """
144
+ widest = max((display_width(item.description) for item in self.items), default=0)
145
+ category = max((display_width(f"[{item.type}]") for item in self.items if item.type), default=0)
146
+ natural = 1 + self.name_width + 2 + widest + category + (2 if category else 1) if self.items else 0
147
+ return Size(natural, self.visible_height)
148
+
149
+ def measure(self, constraints: Constraints) -> Size:
150
+ """Clamp the preferred size into the offered constraints."""
151
+ return constraints.constrain(self.preferred_size())
152
+
153
+ def _scroll_into_view(self) -> None:
154
+ """Keep the highlighted candidate inside the visible window."""
155
+ rows = min(self.rect.height, len(self.items))
156
+ self.top = min(self.top, max(0, len(self.items) - rows))
157
+ if self.selected < self.top:
158
+ self.top = self.selected
159
+ elif self.selected >= self.top + rows:
160
+ self.top = self.selected - rows + 1
161
+
162
+ def render(self, canvas: Canvas) -> None:
163
+ """Draw the visible candidates, banding the selected row full width."""
164
+ if not self.items or self.rect.empty:
165
+ return
166
+ theme = self.theme
167
+ width = self.rect.width
168
+ # Every description starts at the same column, so the command column
169
+ # stays aligned no matter which candidate is highlighted.
170
+ description_column = 1 + self.name_width + 2
171
+ self._scroll_into_view()
172
+ for row in range(min(self.rect.height, len(self.items) - self.top)):
173
+ item = self.items[self.top + row]
174
+ selected = self.top + row == self.selected
175
+ base = (
176
+ Style(foreground=theme.text, background=theme.selection, bold=True)
177
+ if selected
178
+ else Style(foreground=theme.text)
179
+ )
180
+ if selected:
181
+ # A band across the whole row, not just behind the text: that is
182
+ # what makes the current choice readable at a glance.
183
+ canvas.fill(self.rect.x, self.rect.y + row, width, 1, base, " ")
184
+ canvas.draw_text(
185
+ self.rect.x,
186
+ self.rect.y + row,
187
+ truncate(f" {item.display}", width),
188
+ base,
189
+ max_width=width,
190
+ )
191
+ category = f"[{item.type}]" if item.type else ""
192
+ category_width = display_width(category)
193
+ category_column = width - category_width - 1
194
+ show_category = bool(category) and category_column > description_column
195
+ room = (category_column - 1 if show_category else width) - description_column
196
+ if item.description and room > 3:
197
+ canvas.draw_text(
198
+ self.rect.x + description_column,
199
+ self.rect.y + row,
200
+ truncate(item.description, room),
201
+ Style(foreground=theme.muted, background=base.background),
202
+ max_width=room,
203
+ )
204
+ if show_category:
205
+ canvas.draw_text(
206
+ self.rect.x + category_column,
207
+ self.rect.y + row,
208
+ category,
209
+ Style(foreground=theme.muted, background=base.background),
210
+ max_width=category_width,
211
+ )
@@ -0,0 +1,442 @@
1
+ """A full-screen command line: the output of what was run, and a line to type the next command on.
2
+
3
+ The widget is only the screen. It does not start a process or know what a command is: the owner is told what was
4
+ typed (``on_submit``), writes whatever the command prints (:meth:`ConsolePanel.write`, which understands colours and
5
+ carriage returns) and says when it is running and when it has finished. That is what lets the same panel show a
6
+ shell, a build, or a log tail, and what keeps the framework free of the application.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import replace
13
+
14
+ from ..core.events import KeyEvent, MouseAction, MouseEvent
15
+ from ..core.geometry import Point, Rect
16
+ from ..core.host import Host
17
+ from ..core.theme import Theme
18
+ from ..core.widget import Widget
19
+ from ..glyphs import RULE, SEPARATOR
20
+ from ..render import Canvas, Span, Style
21
+ from ..render.ansi import AnsiBuffer
22
+ from ..render.text import ELLIPSIS, character_width, display_width
23
+ from .completion import Completer
24
+ from .textarea import TextArea
25
+
26
+ #: Rows one notch of the wheel moves the output.
27
+ WHEEL_ROWS = 3
28
+
29
+ #: Columns of margin on each side of the output and the input line.
30
+ MARGIN = 2
31
+
32
+ #: Most rows the input line grows to when a long command wraps.
33
+ INPUT_ROWS = 3
34
+
35
+ #: What a candidate can be, as the owner's completer says in ``CompletionItem.type``. The strip of candidates
36
+ #: paints each kind in a colour of its own, as ``ls`` does, so a directory is told from a file before it is chosen.
37
+ DIRECTORY = "directory"
38
+ EXECUTABLE = "executable"
39
+ SYMLINK = "symlink"
40
+ COMMAND = "command"
41
+ FILE = "file"
42
+ REFERENCE = "reference"
43
+ ARGUMENT = "argument"
44
+
45
+
46
+ def keep_tail(text: str, width: int) -> str:
47
+ """Return ``text`` cut from the left to fit ``width`` columns, an ellipsis where it was cut.
48
+
49
+ For a path: the end of it is what says where you are, and the start is the part that every path shares.
50
+ """
51
+ if display_width(text) <= width:
52
+ return text
53
+ if width <= 1:
54
+ return ELLIPSIS[:width]
55
+ kept, used = "", 0
56
+ for character in reversed(text):
57
+ columns = character_width(character)
58
+ if used + columns > width - 1:
59
+ break
60
+ kept, used = character + kept, used + columns
61
+ return ELLIPSIS + kept
62
+
63
+
64
+ def kind_colour(kind: str, theme: Theme) -> str:
65
+ """Return the colour a candidate of this kind is painted in.
66
+
67
+ The hues are the palette's own, so they follow the theme: directories take the lavender the palettes use for
68
+ what the agent reads, programs and executables the accent, links the teal, a branch or tag the orange of what is
69
+ written, and a plain file or a word taken from the history the quiet body colour. A kind nobody named is a
70
+ plain file.
71
+ """
72
+ if kind == DIRECTORY:
73
+ return theme.tools.read
74
+ if kind in (EXECUTABLE, COMMAND):
75
+ return theme.accent
76
+ if kind == SYMLINK:
77
+ return theme.tools.subagent
78
+ if kind == REFERENCE:
79
+ return theme.tools.write
80
+ return theme.subtle
81
+
82
+
83
+ class ConsolePanel(Widget):
84
+ """Show a scrolling output with a line to type commands on.
85
+
86
+ Shape::
87
+
88
+ Shell ~/projects/api exit 0 · 1.2s <- title, location, outcome
89
+ $ pytest -q <- what was typed
90
+ ...... <- what it printed
91
+ 6 passed in 0.24s
92
+ ────────────────────────────────────────────────
93
+ src/ tests/ README.md <- candidates while Tab cycles
94
+ $ git st_ <- the line
95
+ enter run · tab complete · ↑↓ history · esc close <- keys
96
+
97
+ The output follows the newest line until the reader scrolls up (the wheel, PageUp), and stays where it is
98
+ while more arrives; typing, or PageDown to the end, follows again. The panel keeps at most ``max_lines`` lines.
99
+
100
+ Args:
101
+ title: The heading.
102
+ location: Shown beside the title; the directory the commands run in.
103
+ prompt: Drawn before the line to type on, and before each command echoed into the output.
104
+ completer: Candidates for the word under the cursor when Tab is pressed.
105
+ history: Earlier commands, oldest first, which Up and Ctrl-R walk through.
106
+ max_lines: Lines of output kept.
107
+ on_submit: Called with the command when Enter is pressed and nothing is running.
108
+ on_interrupt: Called to stop the running command; returns whether there was one to stop.
109
+ on_close: Called when the reader leaves the panel (Escape, or Ctrl-D on an empty line).
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ *,
115
+ title: str = "Shell",
116
+ location: str = "",
117
+ prompt: str = "$",
118
+ completer: Completer | None = None,
119
+ history: Sequence[str] = (),
120
+ max_lines: int = 5000,
121
+ on_submit: Callable[[str], None] | None = None,
122
+ on_interrupt: Callable[[], bool] | None = None,
123
+ on_close: Callable[[], None] | None = None,
124
+ ) -> None:
125
+ super().__init__()
126
+ self.title = title
127
+ self.location = location
128
+ self.prompt = prompt
129
+ self.on_submit = on_submit
130
+ self.on_interrupt = on_interrupt
131
+ self.on_close = on_close
132
+ self.buffer = AnsiBuffer(max_lines)
133
+ self.input = TextArea(
134
+ prompt=f"{prompt} ",
135
+ completer=completer,
136
+ history_limit=max(200, len(history) + 200),
137
+ on_submit=self._submit,
138
+ on_change=self._typed,
139
+ max_height=INPUT_ROWS,
140
+ )
141
+ self.input.remember(history)
142
+ #: Rows the output is scrolled up from the newest line; zero follows it.
143
+ self.offset = 0
144
+ self.status = ""
145
+ self._running = False
146
+ self._started_at = 0.0
147
+ self._shown_seconds = -1
148
+ self._output_rows = 0
149
+
150
+ # -- what the owner drives ---------------------------------------------
151
+ @property
152
+ def running(self) -> bool:
153
+ """Return whether a command is running."""
154
+ return self._running
155
+
156
+ @property
157
+ def children(self) -> Sequence[Widget]:
158
+ """Return the line the commands are typed on."""
159
+ return (self.input,)
160
+
161
+ def write(self, text: str, style: Style | None = None) -> None:
162
+ """Append output, escape sequences and all; the view follows it unless the reader scrolled up."""
163
+ started = self.buffer.write(text, style)
164
+ if self.offset:
165
+ self.offset += started
166
+ self._changed()
167
+
168
+ def begin(self) -> None:
169
+ """Say that a command has started: the header counts the seconds until :meth:`finish`."""
170
+ self._running = True
171
+ self._started_at = self._now()
172
+ self._shown_seconds = -1
173
+ self.status = ""
174
+ self._changed()
175
+
176
+ def finish(self, status: str) -> None:
177
+ """Say that the command ended, and how (``"exit 0 · 1.2s"``)."""
178
+ self._running = False
179
+ self.status = status
180
+ self._changed()
181
+
182
+ def clear(self) -> None:
183
+ """Forget the output."""
184
+ self.buffer.clear()
185
+ self.offset = 0
186
+ self._changed()
187
+
188
+ def echo(self, command: str) -> None:
189
+ """Write the command as the reader typed it, after the prompt, so the output reads as a session."""
190
+ self.write(f"{self.prompt} {command}\n", Style(bold=True))
191
+
192
+ def set_location(self, location: str) -> None:
193
+ """Change the directory shown beside the title."""
194
+ self.location = location
195
+ self._changed()
196
+
197
+ def close(self) -> None:
198
+ """Leave the panel."""
199
+ if self.on_close is not None:
200
+ self.on_close()
201
+ elif self.app is not None and self.app.screens.top.widget is self:
202
+ self.app.pop_screen()
203
+ self.app.request_layout()
204
+
205
+ def interrupt(self) -> bool:
206
+ """Stop the running command, if there is one; otherwise let Ctrl-C do what it does on any page."""
207
+ if not self._running or self.on_interrupt is None:
208
+ return False
209
+ return self.on_interrupt()
210
+
211
+ # -- the line -----------------------------------------------------------
212
+ def _submit(self, command: str) -> bool | None:
213
+ """Take a command from the line, or refuse it while another is running (the draft stays)."""
214
+ if self._running:
215
+ return False
216
+ self.offset = 0
217
+ if self.on_submit is not None:
218
+ self.on_submit(command)
219
+ return None
220
+
221
+ def _typed(self) -> None:
222
+ """Follow the newest output once the reader starts typing."""
223
+ self.offset = 0
224
+ self._changed()
225
+
226
+ # -- events -------------------------------------------------------------
227
+ def capture_event(self, event, host: Host) -> bool:
228
+ """Escape stops a running command, or else leaves; Ctrl-D on an empty line leaves, as it ends a shell."""
229
+ if not isinstance(event, KeyEvent):
230
+ return False
231
+ if event.key == "escape":
232
+ if not self.interrupt():
233
+ self.close()
234
+ host.invalidate()
235
+ return True
236
+ if event.key == "ctrl_d" and not self.input.text and not self._running:
237
+ self.close()
238
+ return True
239
+ return False
240
+
241
+ def bubble_event(self, event, host: Host) -> bool:
242
+ """Scroll with the keys the line does not use, and with the wheel over the line."""
243
+ return self.handle(event, host)
244
+
245
+ def handle(self, event, host: Host) -> bool:
246
+ """Scroll the output: the wheel, PageUp and PageDown."""
247
+ if isinstance(event, MouseEvent):
248
+ if event.action is MouseAction.SCROLL_UP:
249
+ self._scroll(WHEEL_ROWS)
250
+ elif event.action is MouseAction.SCROLL_DOWN:
251
+ self._scroll(-WHEEL_ROWS)
252
+ else:
253
+ return False
254
+ elif isinstance(event, KeyEvent) and event.key in ("page_up", "page_down"):
255
+ page = max(1, self._output_rows - 1)
256
+ self._scroll(page if event.key == "page_up" else -page)
257
+ else:
258
+ return False
259
+ host.invalidate()
260
+ return True
261
+
262
+ def _scroll(self, rows: int) -> None:
263
+ """Move the view up (positive) or towards the newest line, never past either end."""
264
+ self.offset = max(0, min(self.offset + rows, self._furthest()))
265
+
266
+ def _furthest(self) -> int:
267
+ """Return how far up the view can go: the rows of output beyond what the screen shows."""
268
+ return max(0, self._total_rows(self._text_width()) - self._output_rows)
269
+
270
+ # -- clock --------------------------------------------------------------
271
+ def _now(self) -> float:
272
+ return self.app.clock() if self.app is not None else 0.0
273
+
274
+ def on_tick(self) -> bool:
275
+ """Repaint each second while a command runs, for the header's counter."""
276
+ if not self._running:
277
+ return False
278
+ seconds = int(self._now() - self._started_at)
279
+ if seconds == self._shown_seconds:
280
+ return False
281
+ self._shown_seconds = seconds
282
+ return True
283
+
284
+ def _changed(self) -> None:
285
+ if self.app is not None:
286
+ self.app.invalidate()
287
+ self.invalidate()
288
+
289
+ # -- geometry -----------------------------------------------------------
290
+ def _text_width(self) -> int:
291
+ return max(1, self.rect.width - 2 * MARGIN)
292
+
293
+ def _lines(self) -> list:
294
+ """Return the lines to show: the newest is left out while it is still empty, so no blank row trails."""
295
+ lines = self.buffer.lines
296
+ return lines[:-1] if lines and not lines[-1].chars else lines
297
+
298
+ def _total_rows(self, width: int) -> int:
299
+ return sum(len(line.rows(width)) for line in self._lines())
300
+
301
+ def layout(self, rect: Rect) -> None:
302
+ """Put the line to type on at the bottom, above the key hints."""
303
+ super().layout(rect)
304
+ width = self._text_width()
305
+ height = min(INPUT_ROWS, self.input.preferred_height(width))
306
+ top = rect.bottom - 1 - height
307
+ self.input.layout(Rect(rect.x + MARGIN, top, width, height))
308
+
309
+ # -- painting -----------------------------------------------------------
310
+ def render(self, canvas: Canvas) -> None:
311
+ """Paint the page, the visible output, the candidates and the keys, then the line."""
312
+ rect = self.rect
313
+ if rect.empty:
314
+ return
315
+ theme = self.theme
316
+ page = Style(foreground=theme.text, background=theme.surface_alt)
317
+ muted = replace(page, foreground=theme.muted)
318
+ canvas.fill(rect.x, rect.y, rect.width, rect.height, page)
319
+ room = self._text_width()
320
+ left = rect.x + MARGIN
321
+
322
+ # Heading: the title and the place on the left, the outcome on the right.
323
+ canvas.draw_text(left, rect.y, self.title, replace(page, bold=True), max_width=room)
324
+ title_width = display_width(self.title) + 2
325
+ outcome = self._outcome()
326
+ outcome_width = display_width(outcome)
327
+ place_room = max(0, room - title_width - outcome_width - 2)
328
+ canvas.draw_text(left + title_width, rect.y, keep_tail(self.location, place_room), muted, max_width=place_room)
329
+ if outcome:
330
+ colour = theme.accent if self._running else muted.foreground
331
+ canvas.draw_text(
332
+ left + max(title_width, room - outcome_width),
333
+ rect.y,
334
+ outcome,
335
+ replace(page, foreground=colour),
336
+ max_width=room,
337
+ )
338
+
339
+ # Output, newest at the bottom of its region.
340
+ input_top = self.input.rect.y
341
+ menu = self.input.completion_menu
342
+ strip = 1 if menu is not None else 0
343
+ separator = input_top - 1 - strip
344
+ first, last = rect.y + 1, separator - 1
345
+ self._output_rows = max(0, last - first + 1)
346
+ self.offset = min(self.offset, self._furthest())
347
+ for row, (line, start, end) in enumerate(self._visible(room, self._output_rows)):
348
+ used = 0
349
+ for span in line.spans(start, end):
350
+ shown = self._themed(span, page)
351
+ used += canvas.draw_text(left + used, first + row, shown.text, shown.style, max_width=room - used)
352
+
353
+ canvas.draw_text(
354
+ rect.x, separator, RULE * rect.width, replace(muted, foreground=theme.border), max_width=rect.width
355
+ )
356
+ if menu is not None:
357
+ self._draw_candidates(canvas, menu, left, input_top - 1, room, page)
358
+
359
+ hint = self._hint(room)
360
+ canvas.draw_text(left, rect.bottom - 1, hint, muted, max_width=room)
361
+ self.input.render(canvas)
362
+
363
+ def _outcome(self) -> str:
364
+ """Return what the right of the heading says: the seconds so far while running, else how it ended."""
365
+ if self._running:
366
+ return f"running {int(self._now() - self._started_at)}s"
367
+ return self.status
368
+
369
+ def _hint(self, room: int) -> str:
370
+ """Return the keys that do something now, fewer of them when the line is narrow.
371
+
372
+ Each hint has a rank, and the highest rank goes first until the line fits: history, then scrolling, then
373
+ completion, then the way to run. The way out (rank 0) never goes, so a narrow terminal loses hints and never
374
+ the key that leaves; and once the reader has scrolled up, the way back down outranks completion and history.
375
+ """
376
+ scroll = ("pgdn follow", 2) if self.offset else ("pgup scroll", 4)
377
+ if self._running:
378
+ hints = [("ctrl-c stop", 1), scroll, ("esc stop", 0)]
379
+ else:
380
+ hints = [("enter run", 1), ("tab complete", 3), ("\u2191\u2193 history", 5), scroll, ("esc close", 0)]
381
+ joined = f" {SEPARATOR} "
382
+ while True:
383
+ hint = joined.join(text for text, _ in hints)
384
+ droppable = [rank for _, rank in hints if rank]
385
+ if display_width(hint) <= room or not droppable:
386
+ return hint
387
+ worst = max(droppable)
388
+ hints.pop(max(index for index, (_, rank) in enumerate(hints) if rank == worst))
389
+
390
+ def _visible(self, width: int, height: int) -> list:
391
+ """Return the rows to draw, top to bottom, as ``(line, start, end)``, counting up from the newest by ``offset``."""
392
+ skip = self.offset
393
+ rows: list = []
394
+ for line in reversed(self._lines()):
395
+ for start, end in reversed(line.rows(width)):
396
+ if skip:
397
+ skip -= 1
398
+ continue
399
+ rows.append((line, start, end))
400
+ if len(rows) >= height:
401
+ rows.reverse()
402
+ return rows
403
+ rows.reverse()
404
+ return rows
405
+
406
+ @staticmethod
407
+ def _themed(span: Span, page: Style) -> Span:
408
+ """Give a span the page's colours where the program chose none, so default text follows the theme."""
409
+ style = span.style
410
+ return Span(
411
+ span.text,
412
+ replace(
413
+ style, foreground=style.foreground or page.foreground, background=style.background or page.background
414
+ ),
415
+ )
416
+
417
+ def _draw_candidates(self, canvas: Canvas, menu, left: int, row: int, room: int, page: Style) -> None:
418
+ """Draw the candidates Tab is cycling through on one row, the one in the line highlighted."""
419
+ items, current = menu
420
+ theme = self.theme
421
+ used = 0
422
+ # Begin at the current one, so it is on show however many there are.
423
+ for index in list(range(current, len(items))) + list(range(0, current)):
424
+ label = items[index].display
425
+ width = display_width(label) + 2
426
+ if used + width > room and used:
427
+ canvas.draw_text(
428
+ left + used, row, "\u2026", replace(page, foreground=theme.muted), max_width=room - used
429
+ )
430
+ break
431
+ kind = kind_colour(items[index].type, theme)
432
+ bold = items[index].type == DIRECTORY
433
+ style = (
434
+ Style(foreground=theme.surface_alt, background=kind, bold=True)
435
+ if index == current
436
+ else replace(page, foreground=kind, bold=bold)
437
+ )
438
+ used += canvas.draw_text(left + used, row, f" {label} ", style, max_width=room - used)
439
+
440
+ def cursor(self) -> Point | None:
441
+ """Show the cursor where the line has it."""
442
+ return self.input.cursor()