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,218 @@
|
|
|
1
|
+
"""Named commands and the contextual key bindings that trigger them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeAlias
|
|
9
|
+
|
|
10
|
+
from .events import AnyEvent, KeyEvent, TextEvent
|
|
11
|
+
from .host import Host
|
|
12
|
+
|
|
13
|
+
CommandRun: TypeAlias = Callable[[AnyEvent, Host], bool]
|
|
14
|
+
BindingPriority: TypeAlias = str
|
|
15
|
+
|
|
16
|
+
_PRIORITIES = ("capture", "bubble")
|
|
17
|
+
_SEPARATORS = re.compile(r"[-+\s]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalize_key(value: str) -> str:
|
|
21
|
+
"""Fold every common spelling of a key into one canonical name."""
|
|
22
|
+
return _SEPARATORS.sub("_", value.strip().lower())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def key_id(event: KeyEvent) -> str:
|
|
26
|
+
"""Return the binding name for a key event, including its modifiers.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
event: Named key press; a modifier already folded into ``key`` is not
|
|
30
|
+
repeated in the returned name.
|
|
31
|
+
"""
|
|
32
|
+
name = normalize_key(event.key)
|
|
33
|
+
prefixes = [
|
|
34
|
+
modifier
|
|
35
|
+
for modifier, active in (
|
|
36
|
+
("ctrl", event.control),
|
|
37
|
+
("alt", event.alt),
|
|
38
|
+
("shift", event.shift),
|
|
39
|
+
("meta", event.meta),
|
|
40
|
+
)
|
|
41
|
+
if active and not name.startswith(f"{modifier}_")
|
|
42
|
+
]
|
|
43
|
+
return "_".join([*prefixes, name]) if prefixes else name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def event_key(event: AnyEvent) -> str | None:
|
|
47
|
+
"""Return the binding name an event resolves to, if it can be bound.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
event: Any input event. A terminal delivers a printable character as
|
|
51
|
+
text rather than as a named key, so a binding such as ``"q"`` has to
|
|
52
|
+
be matched from that path as well; only single-character text
|
|
53
|
+
qualifies, which keeps a paste from tripping a binding.
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(event, KeyEvent):
|
|
56
|
+
return key_id(event)
|
|
57
|
+
if isinstance(event, TextEvent) and len(event.text) == 1:
|
|
58
|
+
return normalize_key(event.text)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Command:
|
|
64
|
+
"""One named action a keymap or a command palette can invoke.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
name: Unique lookup key, e.g. ``"interrupt"``.
|
|
68
|
+
run: Receives the event and the host; returns whether it handled it.
|
|
69
|
+
description: Human-readable text for a palette or a help screen.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
run: CommandRun
|
|
74
|
+
description: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CommandRegistry:
|
|
78
|
+
"""Look up commands by name and run them against the live host."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
"""Start with no registered commands."""
|
|
82
|
+
self._commands: dict[str, Command] = {}
|
|
83
|
+
|
|
84
|
+
def register(self, command: Command) -> Command:
|
|
85
|
+
"""Add or replace a command and return it."""
|
|
86
|
+
if not command.name:
|
|
87
|
+
raise ValueError("Command name cannot be empty")
|
|
88
|
+
self._commands[command.name] = command
|
|
89
|
+
return command
|
|
90
|
+
|
|
91
|
+
def add(self, name: str, run: CommandRun, *, description: str = "") -> Command:
|
|
92
|
+
"""Build a command from a name and a run function, then register it.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
name: Lookup key; an existing command with the same name is replaced.
|
|
96
|
+
run: Receives the event and the host, and reports whether it handled
|
|
97
|
+
the event.
|
|
98
|
+
description: Human-readable text for a palette or a help screen.
|
|
99
|
+
"""
|
|
100
|
+
return self.register(Command(name, run, description))
|
|
101
|
+
|
|
102
|
+
def get(self, name: str) -> Command | None:
|
|
103
|
+
"""Return a command by name, or None when it is not registered."""
|
|
104
|
+
return self._commands.get(name)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def names(self) -> tuple[str, ...]:
|
|
108
|
+
"""Return every registered command name, sorted."""
|
|
109
|
+
return tuple(sorted(self._commands))
|
|
110
|
+
|
|
111
|
+
def run(self, name: str, event: AnyEvent, host: Host) -> bool:
|
|
112
|
+
"""Run one command by name, reporting whether it handled the event."""
|
|
113
|
+
command = self._commands.get(name)
|
|
114
|
+
if command is None:
|
|
115
|
+
return False
|
|
116
|
+
return bool(command.run(event, host))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class Binding:
|
|
121
|
+
"""One key-to-command mapping, optionally limited to a live context.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
command: Name resolved through the command registry.
|
|
125
|
+
priority: ``"capture"`` runs before the widget tree sees the event,
|
|
126
|
+
``"bubble"`` only after the focused widget declines it.
|
|
127
|
+
when: Predicate re-checked on every matching key; ``None`` always allows.
|
|
128
|
+
description: Human-readable text for a palette or a help screen.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
command: str
|
|
132
|
+
priority: BindingPriority = "bubble"
|
|
133
|
+
when: Callable[[], bool] | None = None
|
|
134
|
+
description: str = ""
|
|
135
|
+
|
|
136
|
+
def matches(self) -> bool:
|
|
137
|
+
"""Return whether the binding's predicate currently allows it."""
|
|
138
|
+
return self.when is None or self.when()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Keymap:
|
|
142
|
+
"""Contextual bindings split into capture and bubble priorities.
|
|
143
|
+
|
|
144
|
+
Capture bindings run before the widget tree sees the event, so an
|
|
145
|
+
application can reserve keys such as ``ctrl_c``. Bubble bindings run only
|
|
146
|
+
after the focused widget declines the event, which lets a composer keep
|
|
147
|
+
keys like ``tab`` for completion.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
"""Start with no bindings."""
|
|
152
|
+
self._bindings: dict[str, list[Binding]] = {}
|
|
153
|
+
|
|
154
|
+
def bind(
|
|
155
|
+
self,
|
|
156
|
+
key: str,
|
|
157
|
+
command: str,
|
|
158
|
+
*,
|
|
159
|
+
when: Callable[[], bool] | None = None,
|
|
160
|
+
priority: BindingPriority = "bubble",
|
|
161
|
+
description: str = "",
|
|
162
|
+
) -> Binding:
|
|
163
|
+
"""Bind a key spelling to a command at one priority.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
key: Any spelling ``normalize_key`` folds, e.g. ``"Ctrl-C"``.
|
|
167
|
+
command: Name looked up in the command registry when the key fires.
|
|
168
|
+
when: Extra predicate sampled on every key press; the binding is
|
|
169
|
+
skipped while it returns False.
|
|
170
|
+
priority: ``"capture"`` or ``"bubble"``.
|
|
171
|
+
description: Human-readable text for a palette or a help screen.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The stored binding, so callers can keep it for a later unbind.
|
|
175
|
+
"""
|
|
176
|
+
if priority not in _PRIORITIES:
|
|
177
|
+
raise ValueError(f"Unknown binding priority: {priority!r}")
|
|
178
|
+
if not command:
|
|
179
|
+
raise ValueError("Binding command cannot be empty")
|
|
180
|
+
binding = Binding(command, priority, when, description)
|
|
181
|
+
self._bindings.setdefault(normalize_key(key), []).append(binding)
|
|
182
|
+
return binding
|
|
183
|
+
|
|
184
|
+
def unbind(self, key: str, command: str | None = None) -> int:
|
|
185
|
+
"""Remove bindings for one key and return how many were dropped.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
key: Any spelling ``normalize_key`` folds.
|
|
189
|
+
command: Only drop the binding that names this command; ``None``
|
|
190
|
+
removes every binding registered for the key.
|
|
191
|
+
"""
|
|
192
|
+
name = normalize_key(key)
|
|
193
|
+
existing = self._bindings.get(name)
|
|
194
|
+
if not existing:
|
|
195
|
+
return 0
|
|
196
|
+
if command is None:
|
|
197
|
+
self._bindings.pop(name, None)
|
|
198
|
+
return len(existing)
|
|
199
|
+
kept = [binding for binding in existing if binding.command != command]
|
|
200
|
+
if kept:
|
|
201
|
+
self._bindings[name] = kept
|
|
202
|
+
else:
|
|
203
|
+
self._bindings.pop(name, None)
|
|
204
|
+
return len(existing) - len(kept)
|
|
205
|
+
|
|
206
|
+
def bindings_for(self, key: str) -> tuple[Binding, ...]:
|
|
207
|
+
"""Return the bindings registered for one key spelling, in order."""
|
|
208
|
+
return tuple(self._bindings.get(normalize_key(key), ()))
|
|
209
|
+
|
|
210
|
+
def resolve(self, event: AnyEvent, *, priority: BindingPriority = "bubble") -> str | None:
|
|
211
|
+
"""Return the first matching command name, if any binding applies."""
|
|
212
|
+
name = event_key(event)
|
|
213
|
+
if name is None:
|
|
214
|
+
return None
|
|
215
|
+
for binding in self._bindings.get(name, ()):
|
|
216
|
+
if binding.priority == priority and binding.matches():
|
|
217
|
+
return binding.command
|
|
218
|
+
return None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Frame scheduling that keeps animation cost inside a fixed budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Scheduler:
|
|
10
|
+
"""Decide when the application should paint its next frame.
|
|
11
|
+
|
|
12
|
+
Repaints are requested explicitly, while animations run on a bounded
|
|
13
|
+
interval so a busy widget cannot drive the loop at full speed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, max_fps: float = 120.0, clock: Callable[[], float] = monotonic) -> None:
|
|
17
|
+
"""Configure the frame budget and the clock used to time frames.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_fps: Ceiling for animated frames per second; must be positive.
|
|
21
|
+
clock: Monotonic time source, injectable so tests can step time.
|
|
22
|
+
"""
|
|
23
|
+
if max_fps <= 0:
|
|
24
|
+
raise ValueError("max_fps must be positive")
|
|
25
|
+
self.max_fps = max_fps
|
|
26
|
+
self.interval = 1.0 / max_fps
|
|
27
|
+
self.clock = clock
|
|
28
|
+
self.on_request: Callable[[], None] | None = None
|
|
29
|
+
self._last_frame = float("-inf")
|
|
30
|
+
self._dirty = True
|
|
31
|
+
#: Whether the frame the last :meth:`poll` returned was asked for, as opposed to
|
|
32
|
+
#: falling due on an animation's interval alone.
|
|
33
|
+
self.requested = True
|
|
34
|
+
self._animations: set[str] = set()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def animating(self) -> bool:
|
|
38
|
+
"""Return whether any animation token is registered."""
|
|
39
|
+
return bool(self._animations)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def pending(self) -> bool:
|
|
43
|
+
"""Return whether a repaint has been requested and not yet taken."""
|
|
44
|
+
return self._dirty
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def animations(self) -> tuple[str, ...]:
|
|
48
|
+
"""Return the registered animation tokens, sorted."""
|
|
49
|
+
return tuple(sorted(self._animations))
|
|
50
|
+
|
|
51
|
+
def request_repaint(self) -> None:
|
|
52
|
+
"""Mark the frame as stale even if no animation is running."""
|
|
53
|
+
self._dirty = True
|
|
54
|
+
self._notify()
|
|
55
|
+
|
|
56
|
+
def animate(self, token: str, *, active: bool = True) -> None:
|
|
57
|
+
"""Register or release one animation token.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
token: Stable identity for one animation source, e.g.
|
|
61
|
+
``f"spinner:{id(self)}"``; registering the same token twice is
|
|
62
|
+
harmless because the set collapses duplicates.
|
|
63
|
+
active: ``True`` keeps the frame loop alive for this token, ``False``
|
|
64
|
+
releases it without disturbing any other animation.
|
|
65
|
+
"""
|
|
66
|
+
if not token:
|
|
67
|
+
raise ValueError("Animation token cannot be empty")
|
|
68
|
+
if active:
|
|
69
|
+
self._animations.add(token)
|
|
70
|
+
self._notify()
|
|
71
|
+
else:
|
|
72
|
+
self._animations.discard(token)
|
|
73
|
+
|
|
74
|
+
def poll(self, *, now: float | None = None) -> bool:
|
|
75
|
+
"""Return whether a frame is due, recording it as painted.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
now: Overrides the clock; tests use it to pin the frame time.
|
|
79
|
+
"""
|
|
80
|
+
moment = self.clock() if now is None else now
|
|
81
|
+
if not self._dirty:
|
|
82
|
+
if not self._animations or moment - self._last_frame < self.interval:
|
|
83
|
+
return False
|
|
84
|
+
self.requested = self._dirty
|
|
85
|
+
self._dirty = False
|
|
86
|
+
self._last_frame = moment
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def delay(self, *, now: float | None = None) -> float | None:
|
|
90
|
+
"""Return seconds until the next frame, or None when fully idle.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
now: Overrides the clock, matching ``poll``.
|
|
94
|
+
"""
|
|
95
|
+
moment = self.clock() if now is None else now
|
|
96
|
+
if self._dirty:
|
|
97
|
+
return 0.0
|
|
98
|
+
if not self._animations:
|
|
99
|
+
return None
|
|
100
|
+
return max(0.0, self.interval - (moment - self._last_frame))
|
|
101
|
+
|
|
102
|
+
def _notify(self) -> None:
|
|
103
|
+
"""Wake a sleeping driver, which would otherwise miss a late request."""
|
|
104
|
+
if self.on_request is not None:
|
|
105
|
+
self.on_request()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Layers of the application: a widget subtree plus its focus policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .widget import Widget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class Screen:
|
|
13
|
+
"""One stacked layer of the application.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
widget: Root of this layer's subtree.
|
|
17
|
+
name: Debug label such as ``"approval"``; the base layer is ``"main"``.
|
|
18
|
+
modal: Block input from every layer below this one.
|
|
19
|
+
interactive: Whether the layer may receive input at all. A paint-only
|
|
20
|
+
layer (a toast, a status flash) still draws over the shell but lets
|
|
21
|
+
clicks, wheel, and keys fall through to the layer below.
|
|
22
|
+
remembered_focus: Focus to restore when this layer is uncovered again;
|
|
23
|
+
the app writes it when a new layer is pushed on top.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
widget: Widget
|
|
27
|
+
name: str = ""
|
|
28
|
+
modal: bool = False
|
|
29
|
+
interactive: bool = True
|
|
30
|
+
remembered_focus: Widget | None = field(default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScreenStack:
|
|
34
|
+
"""The ordered screen layers, oldest first and topmost last."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, base: Screen) -> None:
|
|
37
|
+
"""Start a stack whose base layer can never be popped.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
base: The permanent bottom layer, named ``"main"`` by ``TuiApp``.
|
|
41
|
+
"""
|
|
42
|
+
self._screens: list[Screen] = [base]
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> Iterator[Screen]:
|
|
45
|
+
"""Iterate from the oldest layer to the newest."""
|
|
46
|
+
return iter(self._screens)
|
|
47
|
+
|
|
48
|
+
def __reversed__(self) -> Iterator[Screen]:
|
|
49
|
+
"""Iterate from the newest layer to the oldest."""
|
|
50
|
+
return reversed(self._screens)
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
"""Return the number of stacked layers."""
|
|
54
|
+
return len(self._screens)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def base(self) -> Screen:
|
|
58
|
+
"""Return the permanent bottom layer."""
|
|
59
|
+
return self._screens[0]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def top(self) -> Screen:
|
|
63
|
+
"""Return the layer that currently receives input."""
|
|
64
|
+
return self._screens[-1]
|
|
65
|
+
|
|
66
|
+
def push(self, screen: Screen) -> Screen:
|
|
67
|
+
"""Add a layer on top and return it.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
screen: The new topmost layer; it becomes the only one that can
|
|
71
|
+
receive input until it is popped or a modal layer covers it.
|
|
72
|
+
"""
|
|
73
|
+
self._screens.append(screen)
|
|
74
|
+
return screen
|
|
75
|
+
|
|
76
|
+
def pop(self) -> Screen | None:
|
|
77
|
+
"""Remove the topmost layer, refusing to drop the base screen."""
|
|
78
|
+
if len(self._screens) == 1:
|
|
79
|
+
return None
|
|
80
|
+
return self._screens.pop()
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Semantic color tokens that widgets render against."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..render.code import CodeTheme
|
|
8
|
+
from ..render.color import blend, parse_hex
|
|
9
|
+
|
|
10
|
+
#: How much of a diff line's colour is mixed into the page to make the fill behind that line.
|
|
11
|
+
DIFF_FILL_SHARE = 0.16
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ToolTheme:
|
|
16
|
+
"""The colour a tool row is painted with, by what the tool did.
|
|
17
|
+
|
|
18
|
+
The built-in palettes keep to a few hues, the way editors keep their tool and
|
|
19
|
+
status colours to a few: a soft lavender for what the agent goes looking with
|
|
20
|
+
(a glob, a grep, a picture, a command, the plan), a warm yellow for reading one
|
|
21
|
+
file (and for ``read_skill``, which reads a file too), orange for what it writes, red for what
|
|
22
|
+
it deletes, teal for a delegated child agent, and mauve for talking to another session. More
|
|
23
|
+
hues than that read as noise in a transcript that is mostly tool rows, so reading is the one
|
|
24
|
+
addition: those rows are the most of any, and the warm yellow says "this is a file I read" where
|
|
25
|
+
the lavender says "this is the agent doing something". That yellow is also the warning colour in the bundled palettes whose yellow and orange differ (every one but
|
|
26
|
+
rose-pine-dawn, which the anti-collision step moved), and the overlap is accepted rather than hidden: it is the one warm hue no tool
|
|
27
|
+
row claims, a skipped row carries a glyph of its own, and the alternative — a step toward the write
|
|
28
|
+
orange — put reads within a few ΔE of writes, which are on screen just as often. The two built-in
|
|
29
|
+
palettes take their tool hues from the Catppuccin family rather than from their page (``dark``'s read
|
|
30
|
+
is mocha's yellow, ``light``'s a shade of its own beside its warning), which is how their other tool
|
|
31
|
+
colours work too. Writing is orange and not green because green is the
|
|
32
|
+
colour of an added line: the heading of an edit sits on the line above its diff, and the same
|
|
33
|
+
green on both made the heading read as one more added line (and a yellow one, close to the
|
|
34
|
+
green in hue and weight, still read as its neighbour). The roles are separate so a theme
|
|
35
|
+
file can pull them apart without touching any code. A tool the palette has never heard of, and
|
|
36
|
+
any role left unset, falls back to :attr:`Theme.accent`.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
read: Reading one file, the least surprising action.
|
|
40
|
+
search: Finding paths or matches across the workspace.
|
|
41
|
+
image: Looking at a picture rather than text.
|
|
42
|
+
write: Creating a file or editing one in place.
|
|
43
|
+
delete: Removing a path; deliberately the same hue as a failure.
|
|
44
|
+
shell: Running a command in the workspace.
|
|
45
|
+
plan: Recording the task list.
|
|
46
|
+
subagent: Delegating work to an isolated child agent, which is its own
|
|
47
|
+
family rather than a file operation.
|
|
48
|
+
message: Talking to another session: ``send_message`` and ``list_sessions``, and the
|
|
49
|
+
header of a message another session's agent sent. One hue for both directions, so a
|
|
50
|
+
conversation between sessions reads as one thing.
|
|
51
|
+
web_search: A search the endpoint ran on the model's behalf (``web_search``). Its own
|
|
52
|
+
role rather than ``search``: it leaves the workspace for the open web, and a theme
|
|
53
|
+
may say so. Spelled ``web-search`` in a theme file's ``[tools]`` table.
|
|
54
|
+
ask_user: A question the model put to the reader (``ask_user``). Its own role rather than
|
|
55
|
+
``plan``: it is the one row that waits for a person, and a theme may colour it apart.
|
|
56
|
+
Spelled ``ask-user`` in a theme file's ``[tools]`` table.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
read: str = "#f9e2af"
|
|
60
|
+
search: str = "#b4befe"
|
|
61
|
+
image: str = "#b4befe"
|
|
62
|
+
write: str = "#ef9a62"
|
|
63
|
+
delete: str = "#e67e80"
|
|
64
|
+
shell: str = "#b4befe"
|
|
65
|
+
plan: str = "#b4befe"
|
|
66
|
+
subagent: str = "#94e2d5"
|
|
67
|
+
message: str = "#cba6f7"
|
|
68
|
+
web_search: str = "#b4befe"
|
|
69
|
+
ask_user: str = "#b4befe"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class Theme:
|
|
74
|
+
"""One named palette expressed as semantic roles, never raw hex.
|
|
75
|
+
|
|
76
|
+
Widgets read roles such as ``accent`` or ``muted`` so a new palette is a
|
|
77
|
+
data change rather than a search-and-replace through every component.
|
|
78
|
+
|
|
79
|
+
The built-in palettes leave the page to the terminal and follow
|
|
80
|
+
Everforest's green-leaning accents
|
|
81
|
+
(``#A7C080`` green, ``#83C092`` aqua, ``#DBBC7F`` yellow, ``#E67E80`` red)
|
|
82
|
+
over the neutral greys, so a transcript can tell reasoning from tool output
|
|
83
|
+
by hue instead of drawing every row in one green.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
name: Palette key used by ``/theme`` and by ``base =`` in a theme file.
|
|
87
|
+
background: Page fill behind everything; ``None`` — what both built-in
|
|
88
|
+
palettes use — leaves the terminal's own background in place, so the
|
|
89
|
+
shell blends into the profile it was started in.
|
|
90
|
+
surface: Raised panel fill.
|
|
91
|
+
surface_alt: Fill shared by the composer and submitted user messages.
|
|
92
|
+
surface_side: Fill of a side question, which is answered on screen and
|
|
93
|
+
deliberately kept out of the conversation. It stays as quiet as any
|
|
94
|
+
other background — the muted body colour has to remain readable on
|
|
95
|
+
it — but it leans a different way from the green-toned neutrals, a
|
|
96
|
+
soft slate, so an aside reads as a note beside the conversation
|
|
97
|
+
rather than as one of its turns.
|
|
98
|
+
surface_peer: Fill of a message that crossed between two sessions, in either direction. It
|
|
99
|
+
is drawn like the reader's own message (the arrow, the same text column), so only its
|
|
100
|
+
background says it is not the reader's. It is as light as ``surface_alt`` and only leans another way —
|
|
101
|
+
Catppuccin Mocha's lavender-grey ``surface0`` in the dark palette, a pale sage in the light one —
|
|
102
|
+
so it reads as a neighbour of the reader's message rather than as a hole or a banner next to it.
|
|
103
|
+
text: Body text.
|
|
104
|
+
muted: Lowest-emphasis text (hints, timestamps, disabled rows).
|
|
105
|
+
subtle: Secondary body text that must stay readable, such as a reasoning
|
|
106
|
+
body or an unchanged diff line.
|
|
107
|
+
accent: Primary highlight: running rows, list markers, the selection
|
|
108
|
+
bullet in a tool row.
|
|
109
|
+
accent_bright: Emphasised accent: the composer prompt, link targets,
|
|
110
|
+
hunk headers.
|
|
111
|
+
warning: Warning state, currently the warning toast.
|
|
112
|
+
error: Failure state: failed tool rows and the error toast.
|
|
113
|
+
diff_added: A line a change adds, and the ``+N`` beside the file. ``None`` — what both built-in
|
|
114
|
+
palettes use — means :attr:`accent`, so a theme that changes the accent changes the diff
|
|
115
|
+
with it; set it to colour additions on their own. Read it through :attr:`addition`.
|
|
116
|
+
diff_removed: A line a change removes, and the ``-N`` beside the file. ``None`` means
|
|
117
|
+
:attr:`error`. Read it through :attr:`deletion`.
|
|
118
|
+
diff_added_fill: The band behind an added line of an edit. ``None`` mixes :attr:`addition` into the page
|
|
119
|
+
(:attr:`background`, or :attr:`surface` when the page is the terminal's), so the band follows the
|
|
120
|
+
palette. Read it through :attr:`addition_fill`.
|
|
121
|
+
diff_removed_fill: The same for a removed line; read it through :attr:`deletion_fill`.
|
|
122
|
+
border: Frame glyphs and separators.
|
|
123
|
+
focus: Focus ring colour; reserved, no built-in widget draws a ring yet.
|
|
124
|
+
selection: Background of a selected or emphasised cell, used by list
|
|
125
|
+
rows and diff segments; text selection uses reverse video instead.
|
|
126
|
+
tools: Per-tool palette for agent rows; see :class:`ToolTheme`.
|
|
127
|
+
code: Per-token palette for fenced code blocks.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
name: str = "dark"
|
|
131
|
+
background: str | None = None
|
|
132
|
+
surface: str = "#2d353b"
|
|
133
|
+
surface_alt: str = "#343f44"
|
|
134
|
+
surface_side: str = "#393a44"
|
|
135
|
+
surface_peer: str = "#313244"
|
|
136
|
+
text: str = "#f2f5f3"
|
|
137
|
+
muted: str = "#7a8478"
|
|
138
|
+
subtle: str = "#9da9a0"
|
|
139
|
+
accent: str = "#a7c080"
|
|
140
|
+
accent_bright: str = "#83c092"
|
|
141
|
+
warning: str = "#dbbc7f"
|
|
142
|
+
error: str = "#e67e80"
|
|
143
|
+
diff_added: str | None = None
|
|
144
|
+
diff_removed: str | None = None
|
|
145
|
+
diff_added_fill: str | None = None
|
|
146
|
+
diff_removed_fill: str | None = None
|
|
147
|
+
border: str = "#3d484d"
|
|
148
|
+
focus: str = "#83c092"
|
|
149
|
+
selection: str = "#4a5f52"
|
|
150
|
+
tools: ToolTheme = ToolTheme()
|
|
151
|
+
code: CodeTheme = CodeTheme()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def addition_fill(self) -> str:
|
|
155
|
+
"""Return the fill behind an added line: ``diff_added_fill`` if set, else the addition colour mixed into the page."""
|
|
156
|
+
return self.diff_added_fill or blend(self.background or self.surface, self.addition, DIFF_FILL_SHARE)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def deletion_fill(self) -> str:
|
|
160
|
+
"""Return the fill behind a removed line: ``diff_removed_fill`` if set, else the deletion colour mixed in."""
|
|
161
|
+
return self.diff_removed_fill or blend(self.background or self.surface, self.deletion, DIFF_FILL_SHARE)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def addition(self) -> str:
|
|
165
|
+
"""Return the colour of an added line: ``diff_added`` if set, else the accent."""
|
|
166
|
+
return self.diff_added or self.accent
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def deletion(self) -> str:
|
|
170
|
+
"""Return the colour of a removed line: ``diff_removed`` if set, else the error colour."""
|
|
171
|
+
return self.diff_removed or self.error
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
DARK = Theme()
|
|
175
|
+
|
|
176
|
+
LIGHT = Theme(
|
|
177
|
+
name="light",
|
|
178
|
+
background=None,
|
|
179
|
+
surface="#ffffff",
|
|
180
|
+
surface_alt="#eceeec",
|
|
181
|
+
surface_side="#e5e6ee",
|
|
182
|
+
surface_peer="#e1ebe4",
|
|
183
|
+
text="#1b211d",
|
|
184
|
+
muted="#68736c",
|
|
185
|
+
subtle="#4c554e",
|
|
186
|
+
accent="#5f7a10",
|
|
187
|
+
accent_bright="#237a5c",
|
|
188
|
+
warning="#8a6314",
|
|
189
|
+
error="#b23b30",
|
|
190
|
+
border="#bdc3af",
|
|
191
|
+
focus="#237a5c",
|
|
192
|
+
selection="#cfe4d6",
|
|
193
|
+
tools=ToolTheme(
|
|
194
|
+
read="#7d5f10",
|
|
195
|
+
search="#5b3fa8",
|
|
196
|
+
image="#5b3fa8",
|
|
197
|
+
write="#b85a14",
|
|
198
|
+
delete="#b23b30",
|
|
199
|
+
shell="#5b3fa8",
|
|
200
|
+
plan="#5b3fa8",
|
|
201
|
+
subagent="#1e7f76",
|
|
202
|
+
message="#8a4a9e",
|
|
203
|
+
),
|
|
204
|
+
code=CodeTheme(
|
|
205
|
+
name="light",
|
|
206
|
+
inline="#256b3b",
|
|
207
|
+
text="#1b211d",
|
|
208
|
+
keyword="#a3234a",
|
|
209
|
+
string="#2f7a48",
|
|
210
|
+
comment="#6d766f",
|
|
211
|
+
number="#8a6314",
|
|
212
|
+
function="#1f5f8b",
|
|
213
|
+
builtin="#5b3fa8",
|
|
214
|
+
operator="#4c554e",
|
|
215
|
+
punctuator="#4c554e",
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def scheme_named(background: str | None) -> str | None:
|
|
221
|
+
"""Return ``"light"`` or ``"dark"`` for a background colour, or None without one.
|
|
222
|
+
|
|
223
|
+
Used to follow the terminal: the luminance of what it says its background is
|
|
224
|
+
picks the palette, so the shell matches the profile it was started in.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
background: ``#rrggbb`` colour, or None when nothing was reported.
|
|
228
|
+
"""
|
|
229
|
+
channels = parse_hex(background) if background else None
|
|
230
|
+
if channels is None:
|
|
231
|
+
return None
|
|
232
|
+
red, green, blue = (channel / 255 for channel in channels)
|
|
233
|
+
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
234
|
+
return "light" if luminance > 0.5 else "dark"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def theme_named(name: str) -> Theme:
|
|
238
|
+
"""Return the built-in theme registered under a name."""
|
|
239
|
+
if name == DARK.name:
|
|
240
|
+
return DARK
|
|
241
|
+
if name == LIGHT.name:
|
|
242
|
+
return LIGHT
|
|
243
|
+
raise ValueError(f"Unknown theme: {name!r}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def theme_names() -> tuple[str, ...]:
|
|
247
|
+
"""Return the built-in palette keys, in the order a picker should list them."""
|
|
248
|
+
return (DARK.name, LIGHT.name)
|