py-sparcli 0.3.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 (61) hide show
  1. py_sparcli-0.3.0.dist-info/METADATA +257 -0
  2. py_sparcli-0.3.0.dist-info/RECORD +61 -0
  3. py_sparcli-0.3.0.dist-info/WHEEL +4 -0
  4. py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  5. sparcli/__init__.py +184 -0
  6. sparcli/core/__init__.py +92 -0
  7. sparcli/core/border.py +160 -0
  8. sparcli/core/color.py +306 -0
  9. sparcli/core/cursor.py +59 -0
  10. sparcli/core/geometry.py +141 -0
  11. sparcli/core/markup.py +215 -0
  12. sparcli/core/render.py +245 -0
  13. sparcli/core/style.py +128 -0
  14. sparcli/core/terminal.py +127 -0
  15. sparcli/core/text.py +143 -0
  16. sparcli/core/theme.py +117 -0
  17. sparcli/core/width.py +244 -0
  18. sparcli/errors.py +38 -0
  19. sparcli/input/__init__.py +42 -0
  20. sparcli/input/calc.py +214 -0
  21. sparcli/input/confirm.py +188 -0
  22. sparcli/input/datepicker.py +298 -0
  23. sparcli/input/editor.py +189 -0
  24. sparcli/input/event.py +551 -0
  25. sparcli/input/field.py +90 -0
  26. sparcli/input/fuzzy.py +401 -0
  27. sparcli/input/guard.py +103 -0
  28. sparcli/input/history.py +160 -0
  29. sparcli/input/line_edit.py +229 -0
  30. sparcli/input/number.py +232 -0
  31. sparcli/input/outcome.py +107 -0
  32. sparcli/input/password.py +233 -0
  33. sparcli/input/prompt.py +134 -0
  34. sparcli/input/select.py +336 -0
  35. sparcli/input/shortcut.py +123 -0
  36. sparcli/input/text.py +564 -0
  37. sparcli/input/textarea.py +242 -0
  38. sparcli/input/validate.py +67 -0
  39. sparcli/output/__init__.py +56 -0
  40. sparcli/output/alert.py +107 -0
  41. sparcli/output/badge.py +82 -0
  42. sparcli/output/box.py +196 -0
  43. sparcli/output/columns.py +157 -0
  44. sparcli/output/compose.py +111 -0
  45. sparcli/output/diff.py +229 -0
  46. sparcli/output/kv.py +175 -0
  47. sparcli/output/layout.py +77 -0
  48. sparcli/output/list.py +274 -0
  49. sparcli/output/live.py +129 -0
  50. sparcli/output/multiprogress.py +73 -0
  51. sparcli/output/pager.py +83 -0
  52. sparcli/output/panel.py +125 -0
  53. sparcli/output/progress.py +233 -0
  54. sparcli/output/rule.py +134 -0
  55. sparcli/output/spinner.py +138 -0
  56. sparcli/output/table/__init__.py +17 -0
  57. sparcli/output/table/plan.py +198 -0
  58. sparcli/output/table/render.py +302 -0
  59. sparcli/output/table/table.py +351 -0
  60. sparcli/output/tree.py +157 -0
  61. sparcli/py.typed +0 -0
sparcli/core/border.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ sparcli.core.border
3
+ ===================
4
+
5
+ Defines border styles and their glyph sets.
6
+
7
+ :class:`BorderType` selects a look (rounded, single, double, thick, ASCII or
8
+ none); :meth:`BorderType.chars` returns the concrete :class:`BorderChars` glyph
9
+ set used to draw boxes, tables and rules. The ASCII set is the fallback for
10
+ terminals without Unicode line-drawing support.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import enum
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class BorderChars:
21
+ """
22
+ The glyphs used to draw a border.
23
+
24
+ Attributes
25
+ ----------
26
+ top_left, top_right, bottom_left, bottom_right : str
27
+ Corner glyphs.
28
+ horizontal, vertical : str
29
+ Edge glyphs.
30
+ cross : str
31
+ The four-way junction glyph.
32
+ tee_down, tee_up, tee_right, tee_left : str
33
+ T-junction glyphs used by tables.
34
+ """
35
+
36
+ top_left: str
37
+ top_right: str
38
+ bottom_left: str
39
+ bottom_right: str
40
+ horizontal: str
41
+ vertical: str
42
+ cross: str
43
+ tee_down: str
44
+ tee_up: str
45
+ tee_right: str
46
+ tee_left: str
47
+
48
+
49
+ _SPACE = BorderChars(
50
+ top_left=" ",
51
+ top_right=" ",
52
+ bottom_left=" ",
53
+ bottom_right=" ",
54
+ horizontal=" ",
55
+ vertical=" ",
56
+ cross=" ",
57
+ tee_down=" ",
58
+ tee_up=" ",
59
+ tee_right=" ",
60
+ tee_left=" ",
61
+ )
62
+
63
+ _ASCII = BorderChars(
64
+ top_left="+",
65
+ top_right="+",
66
+ bottom_left="+",
67
+ bottom_right="+",
68
+ horizontal="-",
69
+ vertical="|",
70
+ cross="+",
71
+ tee_down="+",
72
+ tee_up="+",
73
+ tee_right="+",
74
+ tee_left="+",
75
+ )
76
+
77
+ _SINGLE = BorderChars(
78
+ top_left="┌",
79
+ top_right="┐",
80
+ bottom_left="└",
81
+ bottom_right="┘",
82
+ horizontal="─",
83
+ vertical="│",
84
+ cross="┼",
85
+ tee_down="┬",
86
+ tee_up="┴",
87
+ tee_right="├",
88
+ tee_left="┤",
89
+ )
90
+
91
+ _DOUBLE = BorderChars(
92
+ top_left="╔",
93
+ top_right="╗",
94
+ bottom_left="╚",
95
+ bottom_right="╝",
96
+ horizontal="═",
97
+ vertical="║",
98
+ cross="╬",
99
+ tee_down="╦",
100
+ tee_up="╩",
101
+ tee_right="╠",
102
+ tee_left="╣",
103
+ )
104
+
105
+ _ROUNDED = BorderChars(
106
+ top_left="╭",
107
+ top_right="╮",
108
+ bottom_left="╰",
109
+ bottom_right="╯",
110
+ horizontal="─",
111
+ vertical="│",
112
+ cross="┼",
113
+ tee_down="┬",
114
+ tee_up="┴",
115
+ tee_right="├",
116
+ tee_left="┤",
117
+ )
118
+
119
+ _THICK = BorderChars(
120
+ top_left="┏",
121
+ top_right="┓",
122
+ bottom_left="┗",
123
+ bottom_right="┛",
124
+ horizontal="━",
125
+ vertical="┃",
126
+ cross="╋",
127
+ tee_down="┳",
128
+ tee_up="┻",
129
+ tee_right="┣",
130
+ tee_left="┫",
131
+ )
132
+
133
+
134
+ class BorderType(enum.Enum):
135
+ """A border look; :meth:`chars` returns its glyph set."""
136
+
137
+ NONE = enum.auto()
138
+ ASCII = enum.auto()
139
+ SINGLE = enum.auto()
140
+ DOUBLE = enum.auto()
141
+ ROUNDED = enum.auto()
142
+ THICK = enum.auto()
143
+
144
+ def chars(self) -> BorderChars:
145
+ """Returns the concrete glyph set for this border type."""
146
+ return _BORDER_CHARS[self]
147
+
148
+ def is_none(self) -> bool:
149
+ """Returns whether this border type draws nothing."""
150
+ return self is BorderType.NONE
151
+
152
+
153
+ _BORDER_CHARS: dict[BorderType, BorderChars] = {
154
+ BorderType.NONE: _SPACE,
155
+ BorderType.ASCII: _ASCII,
156
+ BorderType.SINGLE: _SINGLE,
157
+ BorderType.DOUBLE: _DOUBLE,
158
+ BorderType.ROUNDED: _ROUNDED,
159
+ BorderType.THICK: _THICK,
160
+ }
sparcli/core/color.py ADDED
@@ -0,0 +1,306 @@
1
+ """
2
+ sparcli.core.color
3
+ ==================
4
+
5
+ Defines the :class:`Color` type and its downgrade to lower color depths.
6
+
7
+ Colors are authored abstractly (named ANSI colors, 24-bit RGB or 256-color
8
+ indices) and only turned into concrete escape codes at render time via
9
+ :meth:`Color.resolve`, which downgrades to the available
10
+ :class:`~sparcli.core.terminal.ColorSupport`. The named-color set mirrors the
11
+ Rust original, including the quirk that ``GRAY`` is standard ANSI 7 and
12
+ ``WHITE`` is bright ANSI 15.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ from typing import ClassVar, Literal
19
+
20
+ from sparcli.core.terminal import ColorSupport
21
+
22
+ # Downgrade thresholds for mapping an RGB channel onto the 16-color palette.
23
+ _CHANNEL_ON = 0x60
24
+ _CHANNEL_BRIGHT = 0xC0
25
+
26
+ # 256-color index boundaries used when collapsing to the 16-color palette.
27
+ _GRAYSCALE_LIGHT = 244
28
+ _GRAYSCALE_DARK = 232
29
+
30
+ # Resolved-color shapes handed to the renderer, tagged by their first element.
31
+ ResetColor = tuple[Literal["reset"]]
32
+ Ansi16Color = tuple[Literal["ansi16"], int]
33
+ RgbColor = tuple[Literal["rgb"], int, int, int]
34
+ IndexedColor = tuple[Literal["indexed"], int]
35
+ ResolvedColor = ResetColor | Ansi16Color | RgbColor | IndexedColor
36
+
37
+
38
+ class ColorName(enum.Enum):
39
+ """The named ANSI colors, including reset and the eight bright variants."""
40
+
41
+ RESET = enum.auto()
42
+ BLACK = enum.auto()
43
+ RED = enum.auto()
44
+ GREEN = enum.auto()
45
+ YELLOW = enum.auto()
46
+ BLUE = enum.auto()
47
+ MAGENTA = enum.auto()
48
+ CYAN = enum.auto()
49
+ GRAY = enum.auto()
50
+ DARK_GRAY = enum.auto()
51
+ LIGHT_RED = enum.auto()
52
+ LIGHT_GREEN = enum.auto()
53
+ LIGHT_YELLOW = enum.auto()
54
+ LIGHT_BLUE = enum.auto()
55
+ LIGHT_MAGENTA = enum.auto()
56
+ LIGHT_CYAN = enum.auto()
57
+ WHITE = enum.auto()
58
+
59
+
60
+ # Each named color's index in the standard 16-color palette (0..15).
61
+ _ANSI16_INDEX: dict[ColorName, int] = {
62
+ ColorName.BLACK: 0,
63
+ ColorName.RED: 1,
64
+ ColorName.GREEN: 2,
65
+ ColorName.YELLOW: 3,
66
+ ColorName.BLUE: 4,
67
+ ColorName.MAGENTA: 5,
68
+ ColorName.CYAN: 6,
69
+ ColorName.GRAY: 7,
70
+ ColorName.DARK_GRAY: 8,
71
+ ColorName.LIGHT_RED: 9,
72
+ ColorName.LIGHT_GREEN: 10,
73
+ ColorName.LIGHT_YELLOW: 11,
74
+ ColorName.LIGHT_BLUE: 12,
75
+ ColorName.LIGHT_MAGENTA: 13,
76
+ ColorName.LIGHT_CYAN: 14,
77
+ ColorName.WHITE: 15,
78
+ }
79
+
80
+ # Accepted spellings for :meth:`Color.from_name`, including common aliases.
81
+ _NAME_ALIASES: dict[str, ColorName] = {
82
+ "reset": ColorName.RESET,
83
+ "default": ColorName.RESET,
84
+ "black": ColorName.BLACK,
85
+ "red": ColorName.RED,
86
+ "green": ColorName.GREEN,
87
+ "yellow": ColorName.YELLOW,
88
+ "blue": ColorName.BLUE,
89
+ "magenta": ColorName.MAGENTA,
90
+ "purple": ColorName.MAGENTA,
91
+ "cyan": ColorName.CYAN,
92
+ "gray": ColorName.GRAY,
93
+ "grey": ColorName.GRAY,
94
+ "white": ColorName.GRAY,
95
+ "darkgray": ColorName.DARK_GRAY,
96
+ "darkgrey": ColorName.DARK_GRAY,
97
+ "lightred": ColorName.LIGHT_RED,
98
+ "lightgreen": ColorName.LIGHT_GREEN,
99
+ "lightyellow": ColorName.LIGHT_YELLOW,
100
+ "lightblue": ColorName.LIGHT_BLUE,
101
+ "lightmagenta": ColorName.LIGHT_MAGENTA,
102
+ "lightcyan": ColorName.LIGHT_CYAN,
103
+ "brightwhite": ColorName.WHITE,
104
+ }
105
+
106
+ _HEX_DIGITS = 6
107
+
108
+
109
+ class Color:
110
+ """
111
+ A terminal color: a named ANSI color, an RGB triple or a 256-color index.
112
+
113
+ Construct named colors via the class constants (``Color.RED``), true color
114
+ via :meth:`rgb`, and palette colors via :meth:`indexed`. Instances are
115
+ immutable and compare by value.
116
+ """
117
+
118
+ RESET: ClassVar[Color]
119
+ BLACK: ClassVar[Color]
120
+ RED: ClassVar[Color]
121
+ GREEN: ClassVar[Color]
122
+ YELLOW: ClassVar[Color]
123
+ BLUE: ClassVar[Color]
124
+ MAGENTA: ClassVar[Color]
125
+ CYAN: ClassVar[Color]
126
+ GRAY: ClassVar[Color]
127
+ DARK_GRAY: ClassVar[Color]
128
+ LIGHT_RED: ClassVar[Color]
129
+ LIGHT_GREEN: ClassVar[Color]
130
+ LIGHT_YELLOW: ClassVar[Color]
131
+ LIGHT_BLUE: ClassVar[Color]
132
+ LIGHT_MAGENTA: ClassVar[Color]
133
+ LIGHT_CYAN: ClassVar[Color]
134
+ WHITE: ClassVar[Color]
135
+
136
+ __slots__ = ("_name", "_rgb", "_index")
137
+
138
+ def __init__(
139
+ self,
140
+ *,
141
+ name: ColorName | None = None,
142
+ rgb: tuple[int, int, int] | None = None,
143
+ index: int | None = None,
144
+ ) -> None:
145
+ self._name = name
146
+ self._rgb = rgb
147
+ self._index = index
148
+
149
+ @classmethod
150
+ def named(cls, name: ColorName) -> Color:
151
+ """Returns the color for a :class:`ColorName` member."""
152
+ return cls(name=name)
153
+
154
+ @classmethod
155
+ def rgb(cls, red: int, green: int, blue: int) -> Color:
156
+ """
157
+ Returns a 24-bit true color.
158
+
159
+ Parameters
160
+ ----------
161
+ red, green, blue : int
162
+ Channel values, each clamped to the ``0..255`` range.
163
+
164
+ Returns
165
+ -------
166
+ Color
167
+ The RGB color.
168
+ """
169
+ return cls(
170
+ rgb=(_clamp_byte(red), _clamp_byte(green), _clamp_byte(blue))
171
+ )
172
+
173
+ @classmethod
174
+ def indexed(cls, index: int) -> Color:
175
+ """Returns a 256-color palette color for ``index`` (clamped 0..255)."""
176
+ return cls(index=_clamp_byte(index))
177
+
178
+ @classmethod
179
+ def from_name(cls, text: str) -> Color | None:
180
+ """
181
+ Parses a color name (case-insensitive), returning ``None`` if unknown.
182
+
183
+ Parameters
184
+ ----------
185
+ text : str
186
+ A color name such as ``"red"`` or an alias such as ``"purple"``.
187
+
188
+ Returns
189
+ -------
190
+ Color | None
191
+ The matching color, or ``None`` when the name is not recognized.
192
+ """
193
+ name = _NAME_ALIASES.get(text.strip().lower())
194
+ return cls(name=name) if name is not None else None
195
+
196
+ @classmethod
197
+ def from_hex(cls, text: str) -> Color | None:
198
+ """
199
+ Parses ``#rrggbb`` or ``rrggbb`` into an RGB color.
200
+
201
+ Parameters
202
+ ----------
203
+ text : str
204
+ A six-digit hex color, optionally prefixed with ``#``.
205
+
206
+ Returns
207
+ -------
208
+ Color | None
209
+ The RGB color, or ``None`` when the string is malformed.
210
+ """
211
+ digits = text.strip().removeprefix("#")
212
+ if len(digits) != _HEX_DIGITS:
213
+ return None
214
+ try:
215
+ value = int(digits, 16)
216
+ except ValueError:
217
+ return None
218
+ return cls.rgb(value >> 16 & 0xFF, value >> 8 & 0xFF, value & 0xFF)
219
+
220
+ def resolve(self, support: ColorSupport) -> ResolvedColor | None:
221
+ """
222
+ Resolves the color for a given support level, downgrading if needed.
223
+
224
+ Parameters
225
+ ----------
226
+ support : ColorSupport
227
+ The color depth the output stream can display.
228
+
229
+ Returns
230
+ -------
231
+ ResolvedColor | None
232
+ A tagged tuple the renderer turns into an escape code, or ``None``
233
+ when no color should be emitted (``ColorSupport.NONE``).
234
+ """
235
+ if support is ColorSupport.NONE:
236
+ return None
237
+ if self._name is ColorName.RESET:
238
+ return ("reset",)
239
+ if self._name is not None:
240
+ return ("ansi16", _ANSI16_INDEX[self._name])
241
+ if self._rgb is not None:
242
+ if support is ColorSupport.TRUECOLOR:
243
+ return ("rgb", *self._rgb)
244
+ return ("ansi16", _nearest_ansi16(*self._rgb))
245
+ if self._index is not None:
246
+ if support is ColorSupport.TRUECOLOR:
247
+ return ("indexed", self._index)
248
+ return ("ansi16", _indexed_to_ansi16(self._index))
249
+ return None
250
+
251
+ def __eq__(self, other: object) -> bool:
252
+ if not isinstance(other, Color):
253
+ return NotImplemented
254
+ return (
255
+ self._name == other._name
256
+ and self._rgb == other._rgb
257
+ and self._index == other._index
258
+ )
259
+
260
+ def __hash__(self) -> int:
261
+ return hash((self._name, self._rgb, self._index))
262
+
263
+ def __repr__(self) -> str:
264
+ if self._name is not None:
265
+ return f"Color.{self._name.name}"
266
+ if self._rgb is not None:
267
+ return f"Color.rgb{self._rgb}"
268
+ return f"Color.indexed({self._index})"
269
+
270
+
271
+ def _clamp_byte(value: int) -> int:
272
+ """Clamps an integer into the ``0..255`` byte range."""
273
+ return max(0, min(255, value))
274
+
275
+
276
+ def _nearest_ansi16(red: int, green: int, blue: int) -> int:
277
+ """Maps an RGB triple onto the nearest 16-color palette index."""
278
+ base = 0
279
+ if red >= _CHANNEL_ON:
280
+ base |= 1
281
+ if green >= _CHANNEL_ON:
282
+ base |= 2
283
+ if blue >= _CHANNEL_ON:
284
+ base |= 4
285
+ bright = max(red, green, blue) >= _CHANNEL_BRIGHT
286
+ return base + 8 if bright else base
287
+
288
+
289
+ def _indexed_to_ansi16(index: int) -> int:
290
+ """Collapses a 256-color index onto the 16-color palette."""
291
+ if index < 16:
292
+ return index
293
+ if index >= _GRAYSCALE_LIGHT:
294
+ return 15
295
+ if index >= _GRAYSCALE_DARK:
296
+ return 8
297
+ return 7
298
+
299
+
300
+ def _install_named_colors() -> None:
301
+ """Attaches a class constant for every named color after class creation."""
302
+ for name in ColorName:
303
+ setattr(Color, name.name, Color(name=name))
304
+
305
+
306
+ _install_named_colors()
sparcli/core/cursor.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ sparcli.core.cursor
3
+ ==================
4
+
5
+ Hides and restores the terminal hardware cursor around in-place redraws.
6
+
7
+ During spinner and progress animations, and during interactive prompts, the
8
+ library rewrites frames in place; a blinking hardware cursor at the end of a
9
+ frame is distracting, and prompts draw their own styled cursor anyway. These
10
+ helpers hide the cursor on the first request and restore it on the last, both
11
+ idempotently, and register an :mod:`atexit` handler so the cursor is always
12
+ shown again when the process exits (even after an unhandled exception).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import atexit
18
+ import sys
19
+
20
+ _HIDE = "\x1b[?25l"
21
+ _SHOW = "\x1b[?25h"
22
+
23
+ _hidden = False
24
+ _atexit_registered = False
25
+
26
+
27
+ def hide() -> None:
28
+ """Hides the terminal cursor once; a no-op if already hidden."""
29
+ global _hidden, _atexit_registered
30
+ if _hidden:
31
+ return
32
+ _write(_HIDE)
33
+ _hidden = True
34
+ if not _atexit_registered:
35
+ atexit.register(show)
36
+ _atexit_registered = True
37
+
38
+
39
+ def show() -> None:
40
+ """Restores the terminal cursor if it was hidden; a no-op otherwise."""
41
+ global _hidden
42
+ if not _hidden:
43
+ return
44
+ _write(_SHOW)
45
+ _hidden = False
46
+
47
+
48
+ def is_hidden() -> bool:
49
+ """Returns whether the cursor is currently hidden by this module."""
50
+ return _hidden
51
+
52
+
53
+ def _write(escape: str) -> None:
54
+ """Writes an escape to stdout, ignoring closed-stream errors at shutdown."""
55
+ try:
56
+ sys.stdout.write(escape)
57
+ sys.stdout.flush()
58
+ except (OSError, ValueError):
59
+ pass
@@ -0,0 +1,141 @@
1
+ """
2
+ sparcli.core.geometry
3
+ =====================
4
+
5
+ Defines layout primitives: alignment enums, box spacing and titles.
6
+
7
+ :class:`Align` and :class:`VAlign` control horizontal and vertical placement,
8
+ :class:`Position` selects which edge a title sits on, :class:`Edges` models
9
+ box-model spacing (padding/margins), and :class:`Title` is a positioned,
10
+ aligned label used by panels, rules and tables.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import enum
16
+ from dataclasses import dataclass, field, replace
17
+
18
+ from sparcli.core.style import Style
19
+ from sparcli.core.text import IntoText, Line, Span, Text, into_text
20
+
21
+
22
+ class Align(enum.Enum):
23
+ """Horizontal alignment within an available width."""
24
+
25
+ LEFT = enum.auto()
26
+ CENTER = enum.auto()
27
+ RIGHT = enum.auto()
28
+
29
+
30
+ class VAlign(enum.Enum):
31
+ """Vertical alignment within an available height."""
32
+
33
+ TOP = enum.auto()
34
+ MIDDLE = enum.auto()
35
+ BOTTOM = enum.auto()
36
+
37
+
38
+ class Position(enum.Enum):
39
+ """Which edge a title is placed on."""
40
+
41
+ TOP = enum.auto()
42
+ BOTTOM = enum.auto()
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Edges:
47
+ """
48
+ Box-model spacing for the four edges.
49
+
50
+ Attributes
51
+ ----------
52
+ top, right, bottom, left : int
53
+ The spacing on each edge, in cells.
54
+ """
55
+
56
+ top: int = 0
57
+ right: int = 0
58
+ bottom: int = 0
59
+ left: int = 0
60
+
61
+ @classmethod
62
+ def all(cls, value: int) -> Edges:
63
+ """Returns edges with the same spacing on every side."""
64
+ return cls(top=value, right=value, bottom=value, left=value)
65
+
66
+ @classmethod
67
+ def symmetric(cls, vertical: int, horizontal: int) -> Edges:
68
+ """Returns edges with matching vertical and horizontal spacing."""
69
+ return cls(
70
+ top=vertical,
71
+ right=horizontal,
72
+ bottom=vertical,
73
+ left=horizontal,
74
+ )
75
+
76
+ def horizontal(self) -> int:
77
+ """Returns the combined left and right spacing."""
78
+ return self.left + self.right
79
+
80
+ def vertical(self) -> int:
81
+ """Returns the combined top and bottom spacing."""
82
+ return self.top + self.bottom
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class Title:
87
+ """
88
+ A positioned, aligned label rendered inside a border or rule.
89
+
90
+ Attributes
91
+ ----------
92
+ content : Text
93
+ The title text.
94
+ align : Align
95
+ Horizontal alignment along the edge.
96
+ position : Position
97
+ Which edge the title sits on.
98
+ pad : int
99
+ Horizontal padding around the title text, in cells.
100
+ """
101
+
102
+ content: Text = field(default_factory=Text)
103
+ align: Align = Align.LEFT
104
+ position: Position = Position.TOP
105
+ pad: int = 1
106
+
107
+ @classmethod
108
+ def new(cls, content: IntoText) -> Title:
109
+ """Returns a top-left title with default padding."""
110
+ return cls(content=into_text(content))
111
+
112
+ def with_align(self, align: Align) -> Title:
113
+ """Returns a copy with the alignment set."""
114
+ return replace(self, align=align)
115
+
116
+ def with_position(self, position: Position) -> Title:
117
+ """Returns a copy with the edge position set."""
118
+ return replace(self, position=position)
119
+
120
+ def with_pad(self, pad: int) -> Title:
121
+ """Returns a copy with the horizontal padding set."""
122
+ return replace(self, pad=pad)
123
+
124
+ def with_style(self, style: Style) -> Title:
125
+ """Returns a copy with ``style`` patched onto every span."""
126
+ styled = Text(
127
+ [
128
+ Line(
129
+ [
130
+ Span(
131
+ content=span.content,
132
+ style=span.style.patch(style),
133
+ link=span.link,
134
+ )
135
+ for span in line.spans
136
+ ]
137
+ )
138
+ for line in self.content.lines
139
+ ]
140
+ )
141
+ return replace(self, content=styled)