zettcode-plugin-api 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/plugin_api/__init__.py +330 -0
- zettcode/plugin_api/commands.py +383 -0
- zettcode/plugin_api/entries.py +385 -0
- zettcode/plugin_api/host.py +36 -0
- zettcode/plugin_api/mixins.py +67 -0
- zettcode/plugin_api/panels.py +39 -0
- zettcode/plugin_api/pets.py +224 -0
- zettcode/plugin_api/plugin.py +76 -0
- zettcode/plugin_api/presenters.py +277 -0
- zettcode/plugin_api/py.typed +0 -0
- zettcode/plugin_api/rows.py +91 -0
- zettcode/plugin_api/state.py +251 -0
- zettcode/plugin_api/switch.py +87 -0
- zettcode_plugin_api-0.4.0.dist-info/METADATA +21 -0
- zettcode_plugin_api-0.4.0.dist-info/RECORD +16 -0
- zettcode_plugin_api-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""What a pet is, and the catalog that finds one by name.
|
|
2
|
+
|
|
3
|
+
A :class:`Pet` is only data: a palette and, for each mood, the pixel maps it is drawn from, in the encoding
|
|
4
|
+
:func:`pixel_lines` defines (``.`` is transparent, any other character names its colour). It
|
|
5
|
+
knows nothing of the terminal's size, of where it stands, or of the mouse; ``app/ui/widgets/pet.py`` places
|
|
6
|
+
it, moves it, and picks the mood.
|
|
7
|
+
|
|
8
|
+
Pets are offered the way palettes are: a :class:`PetSource` names one and builds it only when it is asked for,
|
|
9
|
+
the built-in plugin offers the ones that ship, and any other plugin offers its own through
|
|
10
|
+
``ctx.tui.register_pet``. :class:`PetCatalog` is what ``/pet`` and the start-up read.
|
|
11
|
+
|
|
12
|
+
Every frame of a pet is the same size, and its height is an even number of pixel rows: two pixels fit in
|
|
13
|
+
one terminal cell.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
from zettcode.tui.render.style import Span, Style, TextLine
|
|
24
|
+
|
|
25
|
+
#: The moods a pet may have. ``idle`` is the only one it must have; a pet without the others stays as it is.
|
|
26
|
+
#: A pet is asleep when it is idle, awake and busy while a request runs, and pleased when it is patted.
|
|
27
|
+
IDLE = "idle"
|
|
28
|
+
WORKING = "working"
|
|
29
|
+
GLAD = "glad"
|
|
30
|
+
|
|
31
|
+
#: What a pet may be called: one word, the way a palette's name is, because it is typed after ``/pet``.
|
|
32
|
+
PET_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
33
|
+
|
|
34
|
+
#: The pet a reader has without choosing one.
|
|
35
|
+
DEFAULT_PET_NAME = "cat"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _brightness(colour: str) -> int:
|
|
39
|
+
"""Return the mean of a ``#rrggbb`` colour's channels, 0 to 255."""
|
|
40
|
+
return sum(int(colour[index : index + 2], 16) for index in (1, 3, 5)) // 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pixel_lines(pixels: tuple[str, ...], palette: dict[str, str]) -> tuple[TextLine, ...]:
|
|
44
|
+
"""Encode each pixel pair with a full/half block and two independent colours.
|
|
45
|
+
|
|
46
|
+
Equal pixels use a full block. Different opaque pixels use an upper half
|
|
47
|
+
block with the lower pixel as its background. When either half is outside
|
|
48
|
+
the silhouette, its background stays unset to preserve the terminal's own
|
|
49
|
+
colour rather than drawing a rectangle around the mark.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
pixels: Equal-length rows, an even number of them; ``.`` is transparent and any other
|
|
53
|
+
character names its colour in ``palette``.
|
|
54
|
+
palette: Colour of each pixel character.
|
|
55
|
+
"""
|
|
56
|
+
lines: list[TextLine] = []
|
|
57
|
+
for index in range(0, len(pixels), 2):
|
|
58
|
+
spans: list[Span] = []
|
|
59
|
+
for upper, lower in zip(pixels[index], pixels[index + 1], strict=True):
|
|
60
|
+
if upper == lower:
|
|
61
|
+
span = Span(" " if upper == "." else "█", Style(foreground=palette.get(upper)))
|
|
62
|
+
elif upper == ".":
|
|
63
|
+
span = Span("▄", Style(foreground=palette[lower]))
|
|
64
|
+
else:
|
|
65
|
+
span = Span("▀", Style(foreground=palette[upper], background=palette.get(lower)))
|
|
66
|
+
if spans and spans[-1].style == span.style:
|
|
67
|
+
spans[-1] = Span(spans[-1].text + span.text, span.style)
|
|
68
|
+
else:
|
|
69
|
+
spans.append(span)
|
|
70
|
+
lines.append(TextLine(tuple(spans)))
|
|
71
|
+
return tuple(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Pet:
|
|
75
|
+
"""A sprite and the frames of each of its moods.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: What the pet is called.
|
|
79
|
+
palette: The colour of each pixel character the frames use.
|
|
80
|
+
poses: For each mood, the frames it cycles through, each frame a tuple of equal-length pixel rows.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: There is no ``idle`` pose, a frame differs in size from the first, or its height is odd.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, name: str, palette: Mapping[str, str], poses: Mapping[str, tuple[tuple[str, ...], ...]]) -> None:
|
|
87
|
+
"""Check the frames agree, and draw each into cells once."""
|
|
88
|
+
if IDLE not in poses:
|
|
89
|
+
raise ValueError(f"pet {name!r} has no {IDLE!r} pose")
|
|
90
|
+
first = poses[IDLE][0]
|
|
91
|
+
size = (len(first[0]), len(first))
|
|
92
|
+
for pose, frames in poses.items():
|
|
93
|
+
if not frames:
|
|
94
|
+
raise ValueError(f"pet {name!r}: pose {pose!r} has no frames")
|
|
95
|
+
for frame in frames:
|
|
96
|
+
if (len(frame[0]), len(frame)) != size or any(len(row) != size[0] for row in frame):
|
|
97
|
+
raise ValueError(f"pet {name!r}: a frame of {pose!r} is not {size[0]}x{size[1]}")
|
|
98
|
+
if size[1] % 2:
|
|
99
|
+
raise ValueError(f"pet {name!r}: {size[1]} pixel rows do not fill whole cells")
|
|
100
|
+
self.name = name
|
|
101
|
+
self.palette = dict(palette)
|
|
102
|
+
self.poses = dict(poses)
|
|
103
|
+
#: Columns and rows of cells the pet takes.
|
|
104
|
+
self.width = size[0]
|
|
105
|
+
self.height = size[1] // 2
|
|
106
|
+
self._cells: dict[str, tuple[tuple[TextLine, ...], ...]] = {
|
|
107
|
+
pose: tuple(pixel_lines(frame, self.palette) for frame in frames) for pose, frames in poses.items()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def swatch(self, count: int = 3) -> tuple[str, ...]:
|
|
111
|
+
"""Return the colours this pet is mostly made of, most used first, for a chip that stands for it.
|
|
112
|
+
|
|
113
|
+
Read from its resting frame. The near-black of eyes and outlines is left out: it says nothing about the
|
|
114
|
+
pet and would disappear on a dark page.
|
|
115
|
+
"""
|
|
116
|
+
used = Counter(char for row in self.poses[IDLE][0] for char in row if char != ".")
|
|
117
|
+
colours: list[str] = []
|
|
118
|
+
for char, _ in used.most_common():
|
|
119
|
+
colour = self.palette.get(char)
|
|
120
|
+
if colour and colour not in colours and _brightness(colour) >= 70:
|
|
121
|
+
colours.append(colour)
|
|
122
|
+
return tuple(colours[:count])
|
|
123
|
+
|
|
124
|
+
def frames(self, pose: str) -> int:
|
|
125
|
+
"""Return how many frames the mood has; a mood the pet lacks has its idle frames."""
|
|
126
|
+
return len(self._cells.get(pose, self._cells[IDLE]))
|
|
127
|
+
|
|
128
|
+
def lines(self, pose: str, step: int = 0) -> tuple[TextLine, ...]:
|
|
129
|
+
"""Return the cells of one frame of a mood, counting steps round the frames it has."""
|
|
130
|
+
frames = self._cells.get(pose, self._cells[IDLE])
|
|
131
|
+
return frames[step % len(frames)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def mirrored(halves: tuple[str, ...]) -> tuple[str, ...]:
|
|
135
|
+
"""Return a frame drawn as its left half only, completed by its own reflection.
|
|
136
|
+
|
|
137
|
+
Most creatures are symmetric, and drawing one side keeps the two from drifting apart as the art is edited. A
|
|
138
|
+
mood that is not symmetric (an eye that looks aside, a tail) replaces whole rows with :func:`edited`.
|
|
139
|
+
"""
|
|
140
|
+
return tuple(row + row[::-1] for row in halves)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def edited(frame: tuple[str, ...], rows: Mapping[int, str]) -> tuple[str, ...]:
|
|
144
|
+
"""Return a frame with some of its pixel rows replaced, which is how one mood is made from another.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: A row index is outside the frame, or a new row is not as wide as the frame.
|
|
148
|
+
"""
|
|
149
|
+
changed = list(frame)
|
|
150
|
+
for index, row in rows.items():
|
|
151
|
+
if not 0 <= index < len(frame) or len(row) != len(frame[0]):
|
|
152
|
+
raise ValueError(f"row {index} cannot be replaced by {row!r}")
|
|
153
|
+
changed[index] = row
|
|
154
|
+
return tuple(changed)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PetSource(NamedTuple):
|
|
158
|
+
"""A pet that something offers by name: the program's own, or a plugin.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
name: What ``/pet`` is typed with; one word.
|
|
162
|
+
load: Builds the pet. Called when it is asked for, never before, so a source costs nothing until picked.
|
|
163
|
+
description: The one-line note the menu shows beside the name, or a function that returns it (called only
|
|
164
|
+
when the menu is opened).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
name: str
|
|
168
|
+
load: Callable[[], Pet]
|
|
169
|
+
description: str | Callable[[], str] = ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class PetCatalog:
|
|
173
|
+
"""Every pet that can be asked for by name, in the order a menu lists them.
|
|
174
|
+
|
|
175
|
+
A source whose name is taken, or is not one word, is ignored: the first one wins, so the pets that ship are
|
|
176
|
+
never displaced by a plugin's and the menu never lists a name twice. Only names are listed up front; a pet is
|
|
177
|
+
built when it is asked for.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
sources: What the program and the plugins offer, in load order.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, sources: Sequence[PetSource] = ()) -> None:
|
|
184
|
+
chosen: dict[str, PetSource] = {}
|
|
185
|
+
for source in sources:
|
|
186
|
+
if source.name not in chosen and PET_NAME.fullmatch(source.name):
|
|
187
|
+
chosen[source.name] = source
|
|
188
|
+
self._sources = chosen
|
|
189
|
+
|
|
190
|
+
def names(self) -> tuple[str, ...]:
|
|
191
|
+
"""Return the names in the order a menu lists them. Nothing is built."""
|
|
192
|
+
return tuple(self._sources)
|
|
193
|
+
|
|
194
|
+
def load(self, name: str) -> Pet:
|
|
195
|
+
"""Return the pet called ``name``.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: There is no such pet, or it could not be built. The message names what is available, so
|
|
199
|
+
a typo is answered by the list.
|
|
200
|
+
"""
|
|
201
|
+
source = self._sources.get(name)
|
|
202
|
+
if source is None:
|
|
203
|
+
available = ", ".join(self.names()) or "none"
|
|
204
|
+
raise ValueError(f"Unknown pet: {name!r}. Available: {available}")
|
|
205
|
+
try:
|
|
206
|
+
return source.load()
|
|
207
|
+
except Exception as error: # noqa: BLE001 - a plugin's pet is its author's code
|
|
208
|
+
raise ValueError(f"Pet {name!r} could not be built: {error}") from error
|
|
209
|
+
|
|
210
|
+
def notes(self) -> dict[str, str]:
|
|
211
|
+
"""Return the one-line note each pet carries, by name; a pet with none is left out.
|
|
212
|
+
|
|
213
|
+
Whatever cannot be read has no note, and is not an error here: it is the error of the person who picks it.
|
|
214
|
+
"""
|
|
215
|
+
notes: dict[str, str] = {}
|
|
216
|
+
for name, source in self._sources.items():
|
|
217
|
+
try:
|
|
218
|
+
described = source.description
|
|
219
|
+
text = described() if callable(described) else described
|
|
220
|
+
except Exception: # noqa: BLE001 - see above
|
|
221
|
+
continue
|
|
222
|
+
if isinstance(text, str) and text.strip():
|
|
223
|
+
notes[name] = text.strip()
|
|
224
|
+
return notes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The terminal's plugin base class and the bundle of what the loaded plugins produced for the terminal.
|
|
2
|
+
|
|
3
|
+
:class:`TuiPlugin` is the engine's :class:`~zettcode.engine.plugins.plugin.Plugin` with the shell's row hooks, and
|
|
4
|
+
it is activated with a :class:`~zettcode.engine.plugins.context.PluginContext`. :class:`TuiPlugins` is the
|
|
5
|
+
engine's bundle plus what only a terminal reads: commands, palettes, pets, presenters and rows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from zettcode.engine.plugins.context import PluginContext
|
|
15
|
+
from zettcode.engine.plugins.plugin import Plugin, Plugins
|
|
16
|
+
from zettcode.plugin_api.mixins import PluginUiMixin
|
|
17
|
+
from zettcode.plugin_api.state import TurnSegment, UiRow
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only, so the imports stay lazy
|
|
20
|
+
from zettcode.plugin_api.commands import Command
|
|
21
|
+
from zettcode.plugin_api.presenters import Presenter
|
|
22
|
+
from zettcode.tui import ThemeSource
|
|
23
|
+
|
|
24
|
+
from .pets import PetSource
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TuiPlugin(Plugin[PluginContext], PluginUiMixin):
|
|
28
|
+
"""A plugin that fills shell slots by overriding a method; ``PluginUiMixin`` holds the four.
|
|
29
|
+
|
|
30
|
+
It is the same :class:`~zettcode.engine.plugins.plugin.Plugin` with the row hooks mixed in. Nothing about it says
|
|
31
|
+
"terminal only": whether a plugin has a screen to draw on is ``ctx.tui is not None``, which is what its
|
|
32
|
+
``activate`` should test before registering a command, a palette, a pet, a presenter or a row.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class TuiPlugins(Plugins):
|
|
38
|
+
"""Everything the installed plugins contributed, for the terminal: the agent's side and the shell's.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
commands: Commands the plugins registered, in load and registration
|
|
42
|
+
order, each typed ``"plugin"``.
|
|
43
|
+
builtin_commands: The first of ``commands`` that came from the plugins compiled into the program, in the same
|
|
44
|
+
order. The shell lists them ahead of its own and puts the rest after everything built in, so an installed
|
|
45
|
+
plugin can add a command but never shadow one of the program's.
|
|
46
|
+
themes: Palettes the plugins offered by name, in load and registration order; the built-in plugin's come
|
|
47
|
+
first. ``/theme`` lists, previews and switches to them, after ``dark`` and ``light``.
|
|
48
|
+
presenters: How plugins asked for entries to be drawn, in load and registration order; the built-in
|
|
49
|
+
presenters come first, at the lowest priority, so a plugin's claim on the same rows wins.
|
|
50
|
+
pets: Pets the plugins offered by name, in load and registration order; the built-in plugin's come first.
|
|
51
|
+
``/pet`` lists, previews and switches to them.
|
|
52
|
+
rows: The header and status rows the plugins fill, each with its left
|
|
53
|
+
and right segments in paint order. The builtin rows are merged in
|
|
54
|
+
first, so an overridden segment keeps its place.
|
|
55
|
+
turn_segments: The items of the line a request ends on, in the order they stand; the built-in plugin's come
|
|
56
|
+
first, so a name a plugin reuses replaces that item in place.
|
|
57
|
+
focus_handlers: Told when the terminal window gains or loses focus, in load order.
|
|
58
|
+
turn_start_handlers: Told when a turn of the conversation starts, in load order.
|
|
59
|
+
turn_end_handlers: Told when a turn of the conversation ends, in load order.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
commands: tuple[Command, ...] = ()
|
|
63
|
+
builtin_commands: tuple[Command, ...] = ()
|
|
64
|
+
themes: tuple[ThemeSource, ...] = ()
|
|
65
|
+
pets: tuple[PetSource, ...] = ()
|
|
66
|
+
presenters: tuple[Presenter, ...] = ()
|
|
67
|
+
rows: tuple[UiRow, ...] = ()
|
|
68
|
+
turn_segments: tuple[TurnSegment, ...] = ()
|
|
69
|
+
focus_handlers: tuple[Callable[[bool], None], ...] = ()
|
|
70
|
+
turn_start_handlers: tuple[Callable[[], None], ...] = ()
|
|
71
|
+
turn_end_handlers: tuple[Callable[[bool], None], ...] = ()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def installed_commands(self) -> tuple[Command, ...]:
|
|
75
|
+
"""Return the commands of the installed plugins: ``commands`` without the built-in ones."""
|
|
76
|
+
return self.commands[len(self.builtin_commands) :]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""What a plugin implements to draw a row: the base class, and how a drawn block is cached.
|
|
2
|
+
|
|
3
|
+
A presenter turns one kind of entry into lines. The built-in ones live in `zettcode.presenters`; a plugin registers its
|
|
4
|
+
own through `ctx.tui.register_presenter`, and the row it draws is the same kind of object, so this — the base class and
|
|
5
|
+
the rule for when a row is drawn again — is part of the plugin contract, not part of the terminal.
|
|
6
|
+
|
|
7
|
+
The registry (`Presenters`) is here and not with the built-in rows because `Presenter.block` takes it as its `owner`:
|
|
8
|
+
the base class names the registry in its own signature, so moving the registry out would make the base class import its
|
|
9
|
+
implementation for a type.
|
|
10
|
+
|
|
11
|
+
The two line sources here are how a block is cached: the entry keeps the last block it was drawn as, its key made of
|
|
12
|
+
the width, the palette, the registry and what the row looks like, so a frame that changes nothing redraws nothing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from dataclasses import fields as dataclass_fields
|
|
20
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
21
|
+
|
|
22
|
+
from zettcode.tui import LineSource, Span, StaticLines, Style, TextLine, Theme
|
|
23
|
+
from zettcode.tui.render import inset_line, layout_rich_lines, wrap_words
|
|
24
|
+
|
|
25
|
+
from .entries import Entry, EntryKind
|
|
26
|
+
|
|
27
|
+
#: Columns a row's content is inset by, past the gutter marker.
|
|
28
|
+
CONTENT_INDENT = 2
|
|
29
|
+
|
|
30
|
+
#: The entry a presenter accepts; each subclass pins it to one entry type.
|
|
31
|
+
E = TypeVar("E", bound=Entry)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: The entry fields that are not part of how a row looks: its identity, and the
|
|
35
|
+
#: slots the cache itself lives in.
|
|
36
|
+
_NOT_LOOK = frozenset({"id", "block", "block_key"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_LOOK_FIELDS: dict[type, tuple[str, ...]] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _look_fields(entry_type: type) -> tuple[str, ...]:
|
|
43
|
+
"""Return the names of the fields a row of this type may look different for.
|
|
44
|
+
|
|
45
|
+
Worked out once per entry class and kept, because it is asked for every entry
|
|
46
|
+
every time the transcript view measures it.
|
|
47
|
+
"""
|
|
48
|
+
names = _LOOK_FIELDS.get(entry_type)
|
|
49
|
+
if names is None:
|
|
50
|
+
names = tuple(field.name for field in dataclass_fields(entry_type) if field.name not in _NOT_LOOK)
|
|
51
|
+
_LOOK_FIELDS[entry_type] = names
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Presenter(Generic[E], ABC):
|
|
56
|
+
"""Turns one kind of entry into rows.
|
|
57
|
+
|
|
58
|
+
The type parameter is the entry the presenter handles, and :attr:`kind` is the
|
|
59
|
+
kind that routes it there, so :meth:`lines` receives exactly that entry.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
kind: The kind of entry this presenter presents; one presenter per kind.
|
|
63
|
+
entry_type: The entry class this presenter is for, when it is for one family of a kind
|
|
64
|
+
rather than the kind as a whole: the presenter of an edit row is the one for
|
|
65
|
+
``EditEntry``, though an edit row is a ``tool`` row like any other. The registry looks
|
|
66
|
+
for the entry's own class first (and its parents') and the kind second.
|
|
67
|
+
gutter: Whether the rows open with a marker the presenter draws in the
|
|
68
|
+
gutter's own column — the user's arrow, the wait row's sparkle, a
|
|
69
|
+
tool's outcome. Every other row is inset past the gutter, so every
|
|
70
|
+
leading glyph sits in one column and every line of content in the next.
|
|
71
|
+
priority: How strongly this presenter claims the rows it draws, when more than one could draw
|
|
72
|
+
them. The higher number wins, and an equal number goes to whichever was registered later,
|
|
73
|
+
so the built-in presenters - priority 0, registered first - are what a transcript uses
|
|
74
|
+
until a plugin registers its own. A plugin that wants to replace only some families of a
|
|
75
|
+
kind, or to beat another plugin, raises this.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
kind: ClassVar[EntryKind]
|
|
79
|
+
entry_type: ClassVar[type[Entry] | None] = None
|
|
80
|
+
gutter: ClassVar[bool] = False
|
|
81
|
+
priority: ClassVar[int] = 0
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def lines(self, entry: E, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
85
|
+
"""Return the rows this entry paints at the available width."""
|
|
86
|
+
|
|
87
|
+
def look(self, entry: E, frame: int) -> object:
|
|
88
|
+
"""Return what the entry's rows depend on, so unchanged ones are not drawn again.
|
|
89
|
+
|
|
90
|
+
The default is every field of the entry. That is the safe direction to be
|
|
91
|
+
wrong in: a field nobody told the cache about changes the key and costs one
|
|
92
|
+
more render, where a hand-written list that forgot it would keep showing
|
|
93
|
+
the row as it was. A presenter narrows it only to leave out something that
|
|
94
|
+
changes without changing the rows, and adds to it only what is not a field.
|
|
95
|
+
"""
|
|
96
|
+
return tuple(getattr(entry, name) for name in _look_fields(type(entry)))
|
|
97
|
+
|
|
98
|
+
def block(self, entry: E, width: int, theme: Theme, frame: int, owner: Presenters) -> LineSource:
|
|
99
|
+
"""Return the entry's cached rows, rendering them again only when the look changed.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
entry: The entry to present; its cache slots hold the last result.
|
|
103
|
+
width: Columns available.
|
|
104
|
+
theme: Palette the rows are drawn in.
|
|
105
|
+
frame: Animation step, which only a presenter that animates reads.
|
|
106
|
+
owner: The registry this call came through, part of the key so a
|
|
107
|
+
transcript given other presenters never reuses these rows.
|
|
108
|
+
"""
|
|
109
|
+
key: tuple[object, ...] = (width, theme, owner, self.look(entry, frame))
|
|
110
|
+
if entry.block is None or entry.block_key != key:
|
|
111
|
+
margin = min(CONTENT_INDENT, max(0, width - 1))
|
|
112
|
+
content_width = width if self.gutter else max(1, width - margin)
|
|
113
|
+
rendered = StaticLines(self.lines(entry, content_width, theme, frame))
|
|
114
|
+
entry.block = rendered if self.gutter else Indented(rendered)
|
|
115
|
+
entry.block_key = key
|
|
116
|
+
return entry.block
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def text_lines(text: str, width: int, style: Style, *, indent: int = 0, words: bool = False) -> list[TextLine]:
|
|
120
|
+
"""Wrap text with the same layout used by RichText and Markdown.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
text: What to wrap; each newline starts a new row.
|
|
124
|
+
width: Columns available.
|
|
125
|
+
style: The ink the text is drawn in.
|
|
126
|
+
indent: Columns left empty at the start of every row.
|
|
127
|
+
words: Break between words and not mid-word. For a sentence a reader is meant to read, such as an
|
|
128
|
+
error naming a command to try; left off, a row breaks at the column, as code and output must.
|
|
129
|
+
"""
|
|
130
|
+
margin = min(indent, max(0, width - 1))
|
|
131
|
+
room = max(1, width - margin)
|
|
132
|
+
if words:
|
|
133
|
+
rows = [piece for line in text.split("\n") for piece in wrap_words(line, room)]
|
|
134
|
+
# A character wider than the whole row (a wide glyph in a column or two) cannot be placed in it;
|
|
135
|
+
# the layout that wraps by column cuts such a row to the budget, and so does this.
|
|
136
|
+
fitted = layout_rich_lines(tuple(TextLine((Span(row, style),)) for row in rows), room, wrap=False)
|
|
137
|
+
return [inset_line(row, margin) for row in fitted]
|
|
138
|
+
source = tuple(TextLine((Span(line, style),)) for line in text.split("\n"))
|
|
139
|
+
return [inset_line(row, margin) for row in layout_rich_lines(source, room)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LeadingGap(LineSource):
|
|
143
|
+
"""LineSource that renders one blank row before a nested source.
|
|
144
|
+
|
|
145
|
+
Streamed answers are served by ``Markdown`` so their parsing stays
|
|
146
|
+
incremental, which means the separating blank line cannot come from the
|
|
147
|
+
generic presenter.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, source: LineSource) -> None:
|
|
151
|
+
"""Wrap the nested line source.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
source: Line source whose rows shift down by one; it is a
|
|
155
|
+
``LineSource`` itself, so the wrapper stays composable.
|
|
156
|
+
"""
|
|
157
|
+
self.source = source
|
|
158
|
+
|
|
159
|
+
def count(self, width: int) -> int:
|
|
160
|
+
"""Return the nested line count plus the leading blank row."""
|
|
161
|
+
return self.source.count(width) + 1
|
|
162
|
+
|
|
163
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
164
|
+
"""Shift the index past the blank row and delegate to the nested source."""
|
|
165
|
+
if index == 0:
|
|
166
|
+
return TextLine()
|
|
167
|
+
return self.source.line(index - 1, width)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Indented(LineSource):
|
|
171
|
+
"""LineSource that shifts every row right by a fixed margin.
|
|
172
|
+
|
|
173
|
+
The margin is drawn with the row's own leading style, so a line that carries
|
|
174
|
+
a background keeps it across the gutter. The nested source wraps at the
|
|
175
|
+
remaining width, which keeps long lines inside the window instead of
|
|
176
|
+
wrapping past its right edge.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, source: LineSource, columns: int = CONTENT_INDENT) -> None:
|
|
180
|
+
"""Wrap ``source`` with ``columns`` cells of leading space.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
source: Line source to shift right.
|
|
184
|
+
columns: Margin in cells; the nested source wraps that much narrower.
|
|
185
|
+
"""
|
|
186
|
+
self.source = source
|
|
187
|
+
self.columns = max(0, columns)
|
|
188
|
+
|
|
189
|
+
def count(self, width: int) -> int:
|
|
190
|
+
"""Return the nested row count, which the margin does not change."""
|
|
191
|
+
return self.source.count(max(1, width - self._margin(width)))
|
|
192
|
+
|
|
193
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
194
|
+
"""Draw the margin, then the nested row at the reduced width."""
|
|
195
|
+
margin = self._margin(width)
|
|
196
|
+
row = self.source.line(index, max(1, width - margin))
|
|
197
|
+
return inset_line(row, margin)
|
|
198
|
+
|
|
199
|
+
def _margin(self, width: int) -> int:
|
|
200
|
+
"""Keep at least one column available for content on narrow screens."""
|
|
201
|
+
return min(self.columns, max(0, width - 1))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Presenters:
|
|
205
|
+
"""The presenters of one transcript: one for each kind of entry, and for the families inside a kind.
|
|
206
|
+
|
|
207
|
+
A presenter is found by the entry's class first, walking up its parents, and by its kind
|
|
208
|
+
second. Most kinds have one presenter and no family (a notice is a notice), and ``tool`` has
|
|
209
|
+
one for tools in general and one for each tool whose entry carries more than the rest.
|
|
210
|
+
|
|
211
|
+
When more than one presenter could draw the same rows, the one with the higher
|
|
212
|
+
:attr:`Presenter.priority` wins, and an equal priority goes to the later registration. That is
|
|
213
|
+
what makes a transcript's own presenters the default: they are registered first, at priority 0,
|
|
214
|
+
so anything a plugin registers for the same rows wins without having to outrank them.
|
|
215
|
+
:meth:`replacing` appends, which is the same thing for a caller holding a registry rather than a
|
|
216
|
+
plugin container.
|
|
217
|
+
|
|
218
|
+
A presenter for a whole kind is a claim on every row of that kind, so it beats the families
|
|
219
|
+
registered before it at its priority: a plugin that draws every tool row draws every tool row.
|
|
220
|
+
A family registered after it, or at a higher priority, is a refinement and keeps its own.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self, presenters: Iterable[Presenter[Any]]) -> None:
|
|
224
|
+
"""Register presenters in order; for one kind or class, the strongest claim wins."""
|
|
225
|
+
self._by_kind: dict[EntryKind, tuple[int, int, Presenter[Any]]] = {}
|
|
226
|
+
self._by_type: dict[type[Entry], tuple[int, int, Presenter[Any]]] = {}
|
|
227
|
+
for order, presenter in enumerate(presenters):
|
|
228
|
+
rank = (presenter.priority, order)
|
|
229
|
+
if presenter.entry_type is not None:
|
|
230
|
+
current = self._by_type.get(presenter.entry_type)
|
|
231
|
+
if current is None or rank >= current[:2]:
|
|
232
|
+
self._by_type[presenter.entry_type] = (*rank, presenter)
|
|
233
|
+
else:
|
|
234
|
+
current = self._by_kind.get(presenter.kind)
|
|
235
|
+
if current is None or rank >= current[:2]:
|
|
236
|
+
self._by_kind[presenter.kind] = (*rank, presenter)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def presenters(self) -> tuple[Presenter[Any], ...]:
|
|
240
|
+
"""Return the presenters that draw something: the kinds' first, then the families'.
|
|
241
|
+
|
|
242
|
+
A family a kind's presenter has outranked is left out: it would never be asked for a row.
|
|
243
|
+
"""
|
|
244
|
+
live = [presenter for _, _, presenter in self._by_kind.values()]
|
|
245
|
+
live.extend(
|
|
246
|
+
presenter
|
|
247
|
+
for priority, order, presenter in self._by_type.values()
|
|
248
|
+
if self._by_kind.get(presenter.kind) is None or (priority, order) > self._by_kind[presenter.kind][:2]
|
|
249
|
+
)
|
|
250
|
+
return tuple(live)
|
|
251
|
+
|
|
252
|
+
def replacing(self, *presenters: Presenter[Any]) -> Presenters:
|
|
253
|
+
"""Return a registry that draws the given kinds, and families, with the given presenters."""
|
|
254
|
+
return Presenters((*self.presenters, *presenters))
|
|
255
|
+
|
|
256
|
+
def for_entry(self, entry: Entry) -> Presenter[Any]:
|
|
257
|
+
"""Return the presenter for an entry: the strongest claim on its class or kind, else say so."""
|
|
258
|
+
family: tuple[int, int, Presenter[Any]] | None = None
|
|
259
|
+
for cls in type(entry).__mro__:
|
|
260
|
+
found = self._by_type.get(cls)
|
|
261
|
+
if found is not None:
|
|
262
|
+
family = found
|
|
263
|
+
break
|
|
264
|
+
kind = self._by_kind.get(entry.kind)
|
|
265
|
+
if family is not None and (kind is None or family[:2] > kind[:2]):
|
|
266
|
+
return family[2]
|
|
267
|
+
if kind is None:
|
|
268
|
+
raise ValueError(f"No presenter for a {entry.kind.value} row")
|
|
269
|
+
return kind[2]
|
|
270
|
+
|
|
271
|
+
def lines(self, entry: Entry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
272
|
+
"""Return the rows an entry paints, rendered now and not cached."""
|
|
273
|
+
return self.for_entry(entry).lines(entry, width, theme, frame)
|
|
274
|
+
|
|
275
|
+
def block(self, entry: Entry, width: int, theme: Theme, frame: int) -> LineSource:
|
|
276
|
+
"""Return an entry's rows as a cached line source, in the gutter's layout."""
|
|
277
|
+
return self.for_entry(entry).block(entry, width, theme, frame, self)
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The line the header and the status line are drawn from, as a plugin's segment may hand it over.
|
|
2
|
+
|
|
3
|
+
A segment hook returns a styled line, a string, or one of these; the shell joins the parts and drops the ones that
|
|
4
|
+
are hidden. It sits in the contract because the type a hook may return is part of the hook.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Collection
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from zettcode.statusline_parts import Part
|
|
13
|
+
from zettcode.tui.glyphs import SEPARATOR
|
|
14
|
+
|
|
15
|
+
__all__ = ["GAP", "JOIN", "Piece", "Row"]
|
|
16
|
+
|
|
17
|
+
JOIN = f" {SEPARATOR} "
|
|
18
|
+
GAP = " "
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Piece(NamedTuple):
|
|
22
|
+
"""A part that is shown, with the characters in front of it: the side's lead, a join, or a gap."""
|
|
23
|
+
|
|
24
|
+
glue: str
|
|
25
|
+
part: Part
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Row:
|
|
29
|
+
"""One side of the header or the status line, appended to part by part.
|
|
30
|
+
|
|
31
|
+
Shape::
|
|
32
|
+
|
|
33
|
+
row = Row(lead=" ").add("app", "The mark and name", "\\u25c8 zettcode").gap()
|
|
34
|
+
row.add("session", "The session's title", "Fix").add("workspace", "The directory", "~/api")
|
|
35
|
+
row.text() # " \\u25c8 zettcode Fix \\u00b7 ~/api"
|
|
36
|
+
|
|
37
|
+
Parts of one run are joined by :data:`JOIN` and runs by :data:`GAP`. A part that is hidden or has no
|
|
38
|
+
text takes no room and no separator either, and a side with nothing left is empty rather than a pair of
|
|
39
|
+
padding spaces — a row too narrow for its right end loses that end, not a stray separator.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, *, lead: str = "", trail: str = "") -> None:
|
|
43
|
+
"""Start an empty side.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
lead: Characters before the first part, when there is one.
|
|
47
|
+
trail: Characters after the last part, when there is one.
|
|
48
|
+
"""
|
|
49
|
+
self.lead = lead
|
|
50
|
+
self.trail = trail
|
|
51
|
+
#: The parts in the order they were added, with ``None`` standing for a break between two runs.
|
|
52
|
+
self._items: list[Part | None] = []
|
|
53
|
+
|
|
54
|
+
def add(self, name: str, summary: str, text: str = "") -> Row:
|
|
55
|
+
"""Append one part, and return this row so the calls chain.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
name: The part's name, which is the switch ``/statusline`` and ``[statusline]`` use.
|
|
59
|
+
summary: What the part shows, in one line, for the panel.
|
|
60
|
+
text: What it shows now; empty when it has nothing to say in this session.
|
|
61
|
+
"""
|
|
62
|
+
self._items.append(Part(name, summary, text))
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def gap(self) -> Row:
|
|
66
|
+
"""Begin a new run: the next part shown stands two spaces after the one before it."""
|
|
67
|
+
self._items.append(None)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def parts(self) -> tuple[Part, ...]:
|
|
71
|
+
"""Return every part that was added, in order, whether or not it has anything to say."""
|
|
72
|
+
return tuple(item for item in self._items if item is not None)
|
|
73
|
+
|
|
74
|
+
def shown(self, hidden: Collection[str] = ()) -> tuple[Piece, ...]:
|
|
75
|
+
"""Return the parts that are neither hidden nor empty, each with what stands in front of it."""
|
|
76
|
+
pieces: list[Piece] = []
|
|
77
|
+
glue = self.lead
|
|
78
|
+
for item in self._items:
|
|
79
|
+
if item is None:
|
|
80
|
+
glue = GAP
|
|
81
|
+
elif item.text and item.name not in hidden:
|
|
82
|
+
pieces.append(Piece(glue, item))
|
|
83
|
+
glue = JOIN
|
|
84
|
+
return tuple(pieces)
|
|
85
|
+
|
|
86
|
+
def text(self, hidden: Collection[str] = ()) -> str:
|
|
87
|
+
"""Return the side as the characters it shows, or ``""`` when nothing is left of it."""
|
|
88
|
+
pieces = self.shown(hidden)
|
|
89
|
+
if not pieces:
|
|
90
|
+
return ""
|
|
91
|
+
return "".join(piece.glue + piece.part.text for piece in pieces) + self.trail
|