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,177 @@
|
|
|
1
|
+
"""Terminal color depth and the SGR encoding derived from it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
from .style import Style
|
|
9
|
+
|
|
10
|
+
_CUBE_STEPS = (0, 95, 135, 175, 215, 255)
|
|
11
|
+
|
|
12
|
+
_ANSI16_RGB: tuple[tuple[int, int, int], ...] = (
|
|
13
|
+
(0, 0, 0),
|
|
14
|
+
(205, 0, 0),
|
|
15
|
+
(0, 205, 0),
|
|
16
|
+
(205, 205, 0),
|
|
17
|
+
(0, 0, 238),
|
|
18
|
+
(205, 0, 205),
|
|
19
|
+
(0, 205, 205),
|
|
20
|
+
(229, 229, 229),
|
|
21
|
+
(127, 127, 127),
|
|
22
|
+
(255, 0, 0),
|
|
23
|
+
(0, 255, 0),
|
|
24
|
+
(255, 255, 0),
|
|
25
|
+
(92, 92, 255),
|
|
26
|
+
(255, 0, 255),
|
|
27
|
+
(0, 255, 255),
|
|
28
|
+
(255, 255, 255),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_ANSI256_RGB: tuple[tuple[int, int, int], ...] = (
|
|
32
|
+
*_ANSI16_RGB,
|
|
33
|
+
*((red, green, blue) for red in _CUBE_STEPS for green in _CUBE_STEPS for blue in _CUBE_STEPS),
|
|
34
|
+
*((level, level, level) for level in range(8, 8 + 24 * 10, 10)),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: The 256-colour palette above its first sixteen entries. Indices 0-15 are the
|
|
38
|
+
#: terminal's own theme colours, so a scheme is free to make "white" a mid grey;
|
|
39
|
+
#: everything above them is a fixed RGB value the terminal renders as written.
|
|
40
|
+
_ANSI256_FIXED_RGB = _ANSI256_RGB[len(_ANSI16_RGB) :]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ColorDepth(IntEnum):
|
|
44
|
+
"""How many distinct colors the terminal can paint."""
|
|
45
|
+
|
|
46
|
+
MONO = 1
|
|
47
|
+
ANSI16 = 16
|
|
48
|
+
ANSI256 = 256
|
|
49
|
+
TRUECOLOR = 1 << 24
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@lru_cache(maxsize=1024)
|
|
53
|
+
def parse_hex(value: str) -> tuple[int, int, int] | None:
|
|
54
|
+
"""Parse ``#rrggbb`` into channels, returning None for anything else."""
|
|
55
|
+
digits = value.removeprefix("#")
|
|
56
|
+
if len(digits) != 6:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return int(digits[0:2], 16), int(digits[2:4], 16), int(digits[4:6], 16)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def blend(first: str, second: str, amount: float) -> str:
|
|
65
|
+
"""Return a colour ``amount`` of the way from ``first`` to ``second``.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
first: Starting ``#rrggbb`` colour, used when ``amount`` is zero.
|
|
69
|
+
second: Ending ``#rrggbb`` colour, used when ``amount`` is one.
|
|
70
|
+
amount: Fraction between them, clamped to ``0..1``.
|
|
71
|
+
|
|
72
|
+
Either colour may be ``None`` or malformed, in which case the first is
|
|
73
|
+
returned unchanged: a palette is data, and an unusable one must not crash a
|
|
74
|
+
repaint.
|
|
75
|
+
"""
|
|
76
|
+
start = parse_hex(first) if first is not None else None
|
|
77
|
+
end = parse_hex(second) if second is not None else None
|
|
78
|
+
if start is None or end is None:
|
|
79
|
+
return first
|
|
80
|
+
fraction = min(1.0, max(0.0, amount))
|
|
81
|
+
channels = (round(a + (b - a) * fraction) for a, b in zip(start, end, strict=True))
|
|
82
|
+
return "#" + "".join(f"{channel:02x}" for channel in channels)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@lru_cache(maxsize=4096)
|
|
86
|
+
def rgb_to_ansi256(red: int, green: int, blue: int) -> int:
|
|
87
|
+
"""Return the closest fixed index in the standard 256-color palette.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
red: Channel value in ``0..255``.
|
|
91
|
+
green: Channel value in ``0..255``.
|
|
92
|
+
blue: Channel value in ``0..255``.
|
|
93
|
+
|
|
94
|
+
The first sixteen palette entries alias the terminal's theme, where a
|
|
95
|
+
"white" that looks right on one setup is a dim grey on another. Body text
|
|
96
|
+
must not degrade like that, so they are skipped: the answer is always one of
|
|
97
|
+
the 240 fixed colours.
|
|
98
|
+
"""
|
|
99
|
+
return _nearest(_ANSI256_FIXED_RGB, red, green, blue) + len(_ANSI16_RGB)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@lru_cache(maxsize=4096)
|
|
103
|
+
def rgb_to_ansi16(red: int, green: int, blue: int) -> int:
|
|
104
|
+
"""Return the closest index in the standard 16-color palette.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
red: Channel value in ``0..255``.
|
|
108
|
+
green: Channel value in ``0..255``.
|
|
109
|
+
blue: Channel value in ``0..255``.
|
|
110
|
+
"""
|
|
111
|
+
return _nearest(_ANSI16_RGB, red, green, blue)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def encode_style(style: Style, depth: ColorDepth = ColorDepth.TRUECOLOR) -> str:
|
|
115
|
+
"""Encode one style as an SGR sequence for the given color depth.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
style: Style to encode; colours are approximated, attributes are exact.
|
|
119
|
+
depth: How precisely the terminal can paint a colour. ``MONO`` drops
|
|
120
|
+
colours entirely but still emits bold, dim, italic, and reverse.
|
|
121
|
+
"""
|
|
122
|
+
# SGR 0 first, so the run starts from a known state and an attribute that
|
|
123
|
+
# was set earlier can never survive into this one.
|
|
124
|
+
codes = ["0"]
|
|
125
|
+
if style.bold:
|
|
126
|
+
codes.append("1") # SGR 1: bold
|
|
127
|
+
if style.dim:
|
|
128
|
+
codes.append("2") # SGR 2: faint
|
|
129
|
+
if style.italic:
|
|
130
|
+
codes.append("3") # SGR 3: italic
|
|
131
|
+
if style.strike:
|
|
132
|
+
codes.append("9") # SGR 9: crossed out
|
|
133
|
+
if style.reverse:
|
|
134
|
+
codes.append("7") # SGR 7: reverse video, used for text selection
|
|
135
|
+
if depth is not ColorDepth.MONO:
|
|
136
|
+
if style.foreground:
|
|
137
|
+
color = _color(style.foreground, depth, foreground=True)
|
|
138
|
+
if color:
|
|
139
|
+
codes.append(color)
|
|
140
|
+
if style.background:
|
|
141
|
+
color = _color(style.background, depth, foreground=False)
|
|
142
|
+
if color:
|
|
143
|
+
codes.append(color)
|
|
144
|
+
return f"\x1b[{';'.join(codes)}m" # CSI ... m: Select Graphic Rendition
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _color(value: str, depth: ColorDepth, *, foreground: bool) -> str:
|
|
148
|
+
"""Encode one hex colour as the SGR parameters the depth supports."""
|
|
149
|
+
rgb = parse_hex(value)
|
|
150
|
+
if rgb is None:
|
|
151
|
+
return ""
|
|
152
|
+
# SGR 38 selects a foreground colour, 48 the background; the parameter that
|
|
153
|
+
# follows picks how the colour is spelled.
|
|
154
|
+
base = 38 if foreground else 48
|
|
155
|
+
if depth is ColorDepth.TRUECOLOR:
|
|
156
|
+
# ``;2;r;g;b`` is 24-bit colour, one byte per channel.
|
|
157
|
+
return f"{base};2;{rgb[0]};{rgb[1]};{rgb[2]}"
|
|
158
|
+
if depth is ColorDepth.ANSI256:
|
|
159
|
+
# ``;5;n`` indexes the 256-colour cube.
|
|
160
|
+
return f"{base};5;{rgb_to_ansi256(*rgb)}"
|
|
161
|
+
index = rgb_to_ansi16(*rgb)
|
|
162
|
+
if index < 8:
|
|
163
|
+
# SGR 30-37 for foreground and 40-47 for background name the base eight.
|
|
164
|
+
return str((30 if foreground else 40) + index)
|
|
165
|
+
# SGR 90-97 and 100-107 are the bright half of the same palette.
|
|
166
|
+
return str((90 if foreground else 100) + index - 8)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _nearest(palette: tuple[tuple[int, int, int], ...], red: int, green: int, blue: int) -> int:
|
|
170
|
+
"""Return the index of the palette entry closest to one RGB triple."""
|
|
171
|
+
best_index = 0
|
|
172
|
+
best_distance: int | None = None
|
|
173
|
+
for index, (candidate_red, candidate_green, candidate_blue) in enumerate(palette):
|
|
174
|
+
distance = (candidate_red - red) ** 2 + (candidate_green - green) ** 2 + (candidate_blue - blue) ** 2
|
|
175
|
+
if best_distance is None or distance < best_distance:
|
|
176
|
+
best_index, best_distance = index, distance
|
|
177
|
+
return best_index
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""ANSI differential renderer that writes only changed cell ranges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from .canvas import Canvas
|
|
9
|
+
from .color import ColorDepth, encode_style
|
|
10
|
+
from .style import Cell, Style
|
|
11
|
+
|
|
12
|
+
#: Synchronized output (DEC private mode 2026). A terminal that knows it holds back the screen from the first byte
|
|
13
|
+
#: after ``BEGIN_SYNC`` to the ``END_SYNC``, then shows the whole frame at once; one that does not know it ignores
|
|
14
|
+
#: the mode, as any terminal ignores a private mode it has no name for. Without it, a terminal may refresh while a
|
|
15
|
+
#: frame is half parsed, and a quick drag shows what the frame was in the middle of: the old place erased and the
|
|
16
|
+
#: new one not yet painted.
|
|
17
|
+
BEGIN_SYNC = "\x1b[?2026h"
|
|
18
|
+
END_SYNC = "\x1b[?2026l"
|
|
19
|
+
#: DECTCEM hide and show, the pair this renderer moves the cursor with.
|
|
20
|
+
HIDE_CURSOR = "\x1b[?25l"
|
|
21
|
+
SHOW_CURSOR = "\x1b[?25h"
|
|
22
|
+
|
|
23
|
+
#: Stands for "no frame has been written yet", which no cursor position equals.
|
|
24
|
+
_UNKNOWN = object()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DifferentialRenderer:
|
|
28
|
+
"""Write only changed cell ranges since the previous frame.
|
|
29
|
+
|
|
30
|
+
The vocabulary is deliberately small: Erase in Display (``ED``), Cursor
|
|
31
|
+
Position (``CUP``), Select Graphic Rendition (``SGR``), the cursor
|
|
32
|
+
show/hide pair, and the begin/end of a synchronized update. Anything else
|
|
33
|
+
would change a mode this renderer has no way to restore.
|
|
34
|
+
|
|
35
|
+
A frame that paints something is one synchronized update, drawn with the
|
|
36
|
+
cursor hidden and shown again where a widget wants it at the end, so a
|
|
37
|
+
terminal that cannot hold the update back still does not show the cursor
|
|
38
|
+
running across the cells as they are written.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, output: TextIO, *, color_depth: ColorDepth = ColorDepth.TRUECOLOR) -> None:
|
|
42
|
+
"""Write frames to ``output`` at the given colour depth.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
output: Text stream to write ANSI to; a real terminal or a StringIO
|
|
46
|
+
in tests.
|
|
47
|
+
color_depth: How precisely colours may be encoded.
|
|
48
|
+
"""
|
|
49
|
+
self.output = output
|
|
50
|
+
self.color_depth = color_depth
|
|
51
|
+
self.previous: Canvas | None = None
|
|
52
|
+
#: Where the cursor was left by the last frame (``None`` is hidden), or ``_UNKNOWN`` before any frame: a
|
|
53
|
+
#: frame that changes no cell and wants the cursor where it already is has nothing to say.
|
|
54
|
+
self._cursor: tuple[int, int] | None | object = _UNKNOWN
|
|
55
|
+
|
|
56
|
+
def reset(self) -> None:
|
|
57
|
+
"""Forget the previous frame so the next render repaints everything."""
|
|
58
|
+
self.previous = None
|
|
59
|
+
self._cursor = _UNKNOWN
|
|
60
|
+
|
|
61
|
+
def render(self, canvas: Canvas, *, cursor: tuple[int, int] | None = None) -> None:
|
|
62
|
+
"""Emit only the cells that differ from the previous frame.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
canvas: The frame to display.
|
|
66
|
+
cursor: Cell to place the terminal cursor on, or ``None`` to hide it.
|
|
67
|
+
A size change on canvas forces a full repaint regardless.
|
|
68
|
+
"""
|
|
69
|
+
full = self.previous is None or (self.previous.width, self.previous.height) != (canvas.width, canvas.height)
|
|
70
|
+
stream = StringIO()
|
|
71
|
+
painted = False
|
|
72
|
+
if full:
|
|
73
|
+
# ED 2 erases the whole display. A resize renumbers every cell, so
|
|
74
|
+
# diffing against the old frame would leave stale glyphs behind.
|
|
75
|
+
stream.write("\x1b[2J")
|
|
76
|
+
painted = True
|
|
77
|
+
for y, row in enumerate(canvas.cells):
|
|
78
|
+
previous_row = None if full or self.previous is None else self.previous.cells[y]
|
|
79
|
+
if previous_row == row:
|
|
80
|
+
continue # An untouched row costs no bytes at all.
|
|
81
|
+
painted = True
|
|
82
|
+
start, end = _changed_bounds(row, previous_row)
|
|
83
|
+
# CUP carries a 1-based row and column; the canvas is 0-based.
|
|
84
|
+
stream.write(f"\x1b[{y + 1};{start + 1}H")
|
|
85
|
+
active_style: Style | None = None
|
|
86
|
+
for cell in row[start : end + 1]:
|
|
87
|
+
if cell.continuation:
|
|
88
|
+
continue # The wide glyph's leading cell painted this column.
|
|
89
|
+
if cell.style != active_style:
|
|
90
|
+
# SGR is stateful, so it is emitted only where a run's style
|
|
91
|
+
# actually changes rather than once per cell.
|
|
92
|
+
stream.write(encode_style(cell.style, self.color_depth))
|
|
93
|
+
active_style = cell.style
|
|
94
|
+
stream.write(cell.character or " ")
|
|
95
|
+
# SGR 0 resets colour and attributes before the next CUP, so a run
|
|
96
|
+
# cannot bleed past the row it belongs to.
|
|
97
|
+
stream.write("\x1b[0m")
|
|
98
|
+
if cursor is None:
|
|
99
|
+
# DECTCEM hide: no widget asked for the cursor this frame. A frame that painted hid it before the first
|
|
100
|
+
# cell, so only a frame that painted nothing has to say so.
|
|
101
|
+
if not painted:
|
|
102
|
+
stream.write(HIDE_CURSOR)
|
|
103
|
+
else:
|
|
104
|
+
x, y = cursor
|
|
105
|
+
# Move first, then show: CUP on its own leaves the cursor hidden.
|
|
106
|
+
stream.write(f"\x1b[{y + 1};{x + 1}H{SHOW_CURSOR}")
|
|
107
|
+
frame = stream.getvalue()
|
|
108
|
+
if painted:
|
|
109
|
+
# The cursor goes first, so a terminal that ignores the update still never shows it mid-frame; the
|
|
110
|
+
# tail puts it back where a widget wants it.
|
|
111
|
+
frame = f"{BEGIN_SYNC}{HIDE_CURSOR}{frame}{END_SYNC}"
|
|
112
|
+
elif cursor == self._cursor:
|
|
113
|
+
# Nothing changed on the screen or at the cursor: there is nothing to send.
|
|
114
|
+
self.previous = canvas
|
|
115
|
+
return
|
|
116
|
+
self._cursor = cursor
|
|
117
|
+
self.output.write(frame)
|
|
118
|
+
self.output.flush()
|
|
119
|
+
self.previous = canvas
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _changed_bounds(row: list[Cell], previous: list[Cell] | None) -> tuple[int, int]:
|
|
123
|
+
"""Return an inclusive column range that safely repaints every changed cell.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
row: The row being painted.
|
|
127
|
+
previous: The same row one frame earlier, or ``None`` when the frame is
|
|
128
|
+
painted from scratch, in which case the whole row counts as changed.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
``(start, end)``, both inclusive. The range is widened outward to whole
|
|
132
|
+
glyphs, so it may cover cells that did not change; ``end < start`` means
|
|
133
|
+
the rows are identical and nothing has to be painted.
|
|
134
|
+
"""
|
|
135
|
+
if previous is None:
|
|
136
|
+
return 0, len(row) - 1
|
|
137
|
+
|
|
138
|
+
changed = [index for index, cell in enumerate(row) if cell != previous[index]]
|
|
139
|
+
if not changed:
|
|
140
|
+
# An empty range keeps this a total function: a caller that forgot to
|
|
141
|
+
# skip an identical row gets "nothing to paint" instead of an IndexError.
|
|
142
|
+
return 0, -1
|
|
143
|
+
start, end = changed[0], changed[-1]
|
|
144
|
+
|
|
145
|
+
# A wide glyph occupies a leading cell plus one or more continuation cells.
|
|
146
|
+
# Include its leader so replacing either half never leaves a stale terminal glyph.
|
|
147
|
+
while start > 0 and (row[start].continuation or previous[start].continuation):
|
|
148
|
+
start -= 1
|
|
149
|
+
while end + 1 < len(row) and (row[end + 1].continuation or previous[end + 1].continuation):
|
|
150
|
+
end += 1
|
|
151
|
+
return start, end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Layout styled lines without requiring a mounted widget.
|
|
2
|
+
|
|
3
|
+
RichText widgets and virtualized LineSources use the same column-aware
|
|
4
|
+
wrapping and indentation. The resulting TextLines are plain render data;
|
|
5
|
+
scrolling and focus stay with the widget that presents them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Literal, TypeAlias
|
|
12
|
+
|
|
13
|
+
from .style import Span, Style, TextLine
|
|
14
|
+
from .text import truncate_spans, wrap_spans
|
|
15
|
+
|
|
16
|
+
Alignment: TypeAlias = Literal["left", "center", "right"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def inset_line(line: TextLine, columns: int) -> TextLine:
|
|
20
|
+
"""Shift a nonblank line right, retaining the style of its first run."""
|
|
21
|
+
if columns <= 0 or not line.spans:
|
|
22
|
+
return line
|
|
23
|
+
first, *rest = line.spans
|
|
24
|
+
return TextLine((Span(" " * columns + first.text, first.style), *rest), metadata=line.metadata)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def align_line(line: TextLine, columns: int) -> TextLine:
|
|
28
|
+
"""Shift a line right by ``columns`` of plain space, leaving its own styling where it is.
|
|
29
|
+
|
|
30
|
+
This is not :func:`inset_line`, which gives the margin the style of the first run. That is
|
|
31
|
+
what a wrapped continuation wants — the indent of a list item belongs to the item — and it
|
|
32
|
+
is wrong for alignment: a run that carries a background would drag it across the whole
|
|
33
|
+
margin, and a line meant to be a pill would be a stripe from the edge of the screen to the
|
|
34
|
+
last word. The margin of an aligned line is empty.
|
|
35
|
+
"""
|
|
36
|
+
if columns <= 0 or not line.spans:
|
|
37
|
+
return line
|
|
38
|
+
return TextLine((Span(" " * columns, Style()), *line.spans), metadata=line.metadata)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def layout_rich_lines(
|
|
42
|
+
lines: Sequence[TextLine],
|
|
43
|
+
width: int,
|
|
44
|
+
*,
|
|
45
|
+
wrap: bool = True,
|
|
46
|
+
align: Alignment = "left",
|
|
47
|
+
) -> tuple[TextLine, ...]:
|
|
48
|
+
"""Wrap or truncate styled lines within a display-column budget.
|
|
49
|
+
|
|
50
|
+
``TextLine.metadata`` may contain an integer hanging indent. That indent
|
|
51
|
+
is applied to continuation rows, so a list wraps beneath its own text.
|
|
52
|
+
Alignment adds space only to rows narrower than the available width.
|
|
53
|
+
"""
|
|
54
|
+
if width <= 0:
|
|
55
|
+
return ()
|
|
56
|
+
if align not in ("left", "center", "right"):
|
|
57
|
+
raise ValueError(f"Unknown alignment: {align!r}")
|
|
58
|
+
laid_out: list[TextLine] = []
|
|
59
|
+
for line in lines:
|
|
60
|
+
indent = line.metadata if isinstance(line.metadata, int) and line.metadata > 0 else 0
|
|
61
|
+
if wrap:
|
|
62
|
+
rows = wrap_spans(line.spans, width)
|
|
63
|
+
if len(rows) > 1 and indent:
|
|
64
|
+
remainder = tuple(span for row in rows[1:] for span in row)
|
|
65
|
+
rows = [rows[0], *wrap_spans(remainder, max(1, width - min(indent, width - 1)))]
|
|
66
|
+
else:
|
|
67
|
+
rows = [truncate_spans(line.spans, width)]
|
|
68
|
+
for position, spans in enumerate(rows):
|
|
69
|
+
row = TextLine(tuple(spans))
|
|
70
|
+
if wrap and position and indent:
|
|
71
|
+
row = inset_line(row, min(indent, width - 1))
|
|
72
|
+
used = row.width
|
|
73
|
+
if used > width:
|
|
74
|
+
row = TextLine(truncate_spans(row.spans, width))
|
|
75
|
+
used = row.width
|
|
76
|
+
if align != "left" and used < width and row.spans:
|
|
77
|
+
gap = (width - used) // 2 if align == "center" else width - used
|
|
78
|
+
row = align_line(row, gap)
|
|
79
|
+
laid_out.append(row)
|
|
80
|
+
return tuple(laid_out)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Styled text primitives shared by every render target."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only, avoids an import cycle
|
|
10
|
+
from .rich_text import Alignment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Style:
|
|
15
|
+
"""Terminal style independent from ANSI encoding.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
foreground: Hex colour, or ``None`` to leave the terminal default.
|
|
19
|
+
background: Hex colour, or ``None`` for no background.
|
|
20
|
+
bold: Emit the bold attribute.
|
|
21
|
+
italic: Emit the italic attribute.
|
|
22
|
+
dim: Emit the faint attribute, used for de-emphasised structure.
|
|
23
|
+
strike: Emit the crossed-out attribute, used for text a writer removed.
|
|
24
|
+
reverse: Swap foreground and background; a text selection is painted
|
|
25
|
+
this way so it works over any colour underneath.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
foreground: str | None = None
|
|
29
|
+
background: str | None = None
|
|
30
|
+
bold: bool = False
|
|
31
|
+
italic: bool = False
|
|
32
|
+
dim: bool = False
|
|
33
|
+
strike: bool = False
|
|
34
|
+
reverse: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_STYLE = Style()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_measure: Callable[[str], int] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _display_width() -> Callable[[str], int]:
|
|
44
|
+
"""Return ``text.display_width``, imported the first time it is needed and kept.
|
|
45
|
+
|
|
46
|
+
``text`` builds on the types in this module, so this module cannot import it when
|
|
47
|
+
it loads; asking for the function inside every call was a statement the
|
|
48
|
+
interpreter had to resolve each time, and a transcript measures a span tens of
|
|
49
|
+
thousands of times.
|
|
50
|
+
"""
|
|
51
|
+
global _measure
|
|
52
|
+
if _measure is None:
|
|
53
|
+
from .text import display_width
|
|
54
|
+
|
|
55
|
+
_measure = display_width
|
|
56
|
+
return _measure
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Span:
|
|
61
|
+
"""One styled text fragment.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
text: Characters that share one style; no newlines.
|
|
65
|
+
style: Style applied to every character in ``text``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
text: str
|
|
69
|
+
style: Style = DEFAULT_STYLE
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def width(self) -> int:
|
|
73
|
+
"""Return the columns this fragment paints, including wide glyphs."""
|
|
74
|
+
return _display_width()(self.text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class TextLine:
|
|
79
|
+
"""One logical line with optional component-owned metadata.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
spans: Styled fragments in paint order.
|
|
83
|
+
metadata: Free-form slot for the component that produced the line; the
|
|
84
|
+
canvas ignores it, hit-testing and callers may read it.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
spans: tuple[Span, ...] = ()
|
|
88
|
+
metadata: object | None = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def text(self) -> str:
|
|
92
|
+
"""Return the concatenated text of every span."""
|
|
93
|
+
return "".join(span.text for span in self.spans)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def width(self) -> int:
|
|
97
|
+
"""Return the displayed column width of this line."""
|
|
98
|
+
measure = _display_width()
|
|
99
|
+
return sum(measure(span.text) for span in self.spans)
|
|
100
|
+
|
|
101
|
+
def layout(self, width: int, *, wrap: bool = True, align: Alignment = "left") -> tuple[TextLine, ...]:
|
|
102
|
+
"""Lay out this line using the same rules as a RichText widget."""
|
|
103
|
+
from .rich_text import layout_rich_lines
|
|
104
|
+
|
|
105
|
+
return layout_rich_lines((self,), width, wrap=wrap, align=align)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class Cell:
|
|
110
|
+
"""One terminal column; wide glyph tails use continuation=True.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
character: Glyph drawn in this column, or ``""`` on a continuation cell.
|
|
114
|
+
style: Style of the cell; a continuation cell repeats its leader's style.
|
|
115
|
+
continuation: This cell is the second half of a wide glyph, so the
|
|
116
|
+
renderer must skip it and the leader must be repainted together.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
character: str = " "
|
|
120
|
+
style: Style = DEFAULT_STYLE
|
|
121
|
+
continuation: bool = False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""A highlight that travels across a label, one span per brightness step.
|
|
2
|
+
|
|
3
|
+
A wait is easier to read when something moves through it than when the whole row
|
|
4
|
+
flips between two states: the eye follows the bright point, and the rest of the
|
|
5
|
+
label keeps its ordinary colour, so the text never disappears mid-pulse.
|
|
6
|
+
|
|
7
|
+
The effect is a span, not a widget. :class:`SweepSpan` is an ordinary
|
|
8
|
+
:class:`~zettcode.tui.Span` — same text, same resting style — that also carries
|
|
9
|
+
the colour of the bright point and the width of the ramp around it;
|
|
10
|
+
:func:`sweep_spans` resolves one into the plain spans a renderer draws for a
|
|
11
|
+
single frame.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
|
|
18
|
+
from .color import blend
|
|
19
|
+
from .style import DEFAULT_STYLE, Span, Style
|
|
20
|
+
from .text import character_width, display_width
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class SweepSpan(Span):
|
|
25
|
+
"""A run of text with a highlight that moves across it.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
peak: Style of the character the highlight sits on; the colour in it is
|
|
29
|
+
blended towards :attr:`Span.style` on the way out, so a palette only
|
|
30
|
+
has to name the bright end.
|
|
31
|
+
ramp: Columns on either side that fade back to the resting style. The
|
|
32
|
+
highlight also spends this many columns off each end of the text, so
|
|
33
|
+
the sweep pauses instead of jumping back to the start.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
peak: Style = DEFAULT_STYLE
|
|
37
|
+
ramp: int = 4
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def travel(self) -> int:
|
|
41
|
+
"""Return how many columns one full pass takes, ends included."""
|
|
42
|
+
return display_width(self.text) + max(1, self.ramp) * 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sweep_spans(span: SweepSpan, step: int) -> tuple[Span, ...]:
|
|
46
|
+
"""Resolve one sweep into the spans to draw for a frame.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
span: The label, its resting style, and its highlight.
|
|
50
|
+
step: Column the highlight has travelled to; it wraps every
|
|
51
|
+
:attr:`SweepSpan.travel` columns, so a renderer can pass a frame
|
|
52
|
+
counter straight in.
|
|
53
|
+
|
|
54
|
+
Adjacent characters that ended up with the same style are merged, so a label
|
|
55
|
+
costs at most ``2 * ramp + 1`` runs per frame.
|
|
56
|
+
"""
|
|
57
|
+
ramp = max(1, span.ramp)
|
|
58
|
+
centre = (step % span.travel) - ramp
|
|
59
|
+
runs: list[Span] = []
|
|
60
|
+
column = 0
|
|
61
|
+
for character in span.text:
|
|
62
|
+
amount = 1.0 - abs(column - centre) / (ramp + 1)
|
|
63
|
+
runs.append(Span(character, _style_at(span, amount)))
|
|
64
|
+
column += character_width(character)
|
|
65
|
+
return _merge(runs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _style_at(span: SweepSpan, amount: float) -> Style:
|
|
69
|
+
"""Return the style for one character, ``amount`` along the ramp."""
|
|
70
|
+
if amount <= 0:
|
|
71
|
+
return span.style
|
|
72
|
+
if amount >= 1:
|
|
73
|
+
return span.peak
|
|
74
|
+
if span.style.foreground is None or span.peak.foreground is None:
|
|
75
|
+
return span.peak if amount > 0.5 else span.style
|
|
76
|
+
return replace(span.style, foreground=blend(span.style.foreground, span.peak.foreground, amount))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _merge(runs: list[Span]) -> tuple[Span, ...]:
|
|
80
|
+
"""Join adjacent runs that share a style, so a frame draws few spans."""
|
|
81
|
+
merged: list[Span] = []
|
|
82
|
+
for run in runs:
|
|
83
|
+
if merged and merged[-1].style == run.style:
|
|
84
|
+
merged[-1] = Span(merged[-1].text + run.text, run.style)
|
|
85
|
+
else:
|
|
86
|
+
merged.append(run)
|
|
87
|
+
return tuple(merged)
|