zettcode-tui 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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()
|