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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. 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)