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.
@@ -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