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
@@ -0,0 +1,138 @@
1
+ """
2
+ sparcli.output.spinner
3
+ ======================
4
+
5
+ Defines animated spinners for in-progress operations.
6
+
7
+ A :class:`Spinner` is a single line that cycles through a set of glyphs while a
8
+ task runs, then closes with a success or failure marker. The static
9
+ :meth:`Spinner.frame` builds the current line without touching the terminal,
10
+ while :meth:`Spinner.tick` advances the animation and redraws it in place via
11
+ :class:`~sparcli.output.live.InPlace`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import enum
17
+
18
+ from sparcli.core.color import Color
19
+ from sparcli.core.render import Rendered
20
+ from sparcli.core.style import Style
21
+ from sparcli.core.text import Line, Span
22
+ from sparcli.core.theme import theme
23
+ from sparcli.output.live import InPlace
24
+
25
+
26
+ class SpinnerStyle(enum.Enum):
27
+ """The animation style of a spinner."""
28
+
29
+ BRAILLE = enum.auto()
30
+ PIPE = enum.auto()
31
+ DOTS = enum.auto()
32
+ ARROW = enum.auto()
33
+
34
+
35
+ # Animation frames for each spinner style, keyed by the style enum.
36
+ _SPINNER_FRAMES: dict[SpinnerStyle, tuple[str, ...]] = {
37
+ SpinnerStyle.BRAILLE: (
38
+ "⠋",
39
+ "⠙",
40
+ "⠹",
41
+ "⠸",
42
+ "⠼",
43
+ "⠴",
44
+ "⠦",
45
+ "⠧",
46
+ "⠇",
47
+ "⠏",
48
+ ),
49
+ SpinnerStyle.PIPE: ("|", "/", "-", "\\"),
50
+ SpinnerStyle.DOTS: ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"),
51
+ SpinnerStyle.ARROW: ("←", "↖", "↑", "↗", "→", "↘", "↓", "↙"),
52
+ }
53
+
54
+ # Two-step markers: (unicode, ascii) for success and failure respectively.
55
+ _SUCCESS_GLYPH = ("✔", "+")
56
+ _FAILURE_GLYPH = ("✖", "x")
57
+
58
+
59
+ class Spinner:
60
+ """An animated, single-line spinner with a label."""
61
+
62
+ __slots__ = (
63
+ "_style",
64
+ "_color",
65
+ "_label",
66
+ "_label_style",
67
+ "_frame_index",
68
+ "_inplace",
69
+ )
70
+
71
+ def __init__(
72
+ self, label: str = "", *, inplace: InPlace | None = None
73
+ ) -> None:
74
+ self._style = SpinnerStyle.BRAILLE
75
+ self._color = theme().accent
76
+ self._label = label
77
+ self._label_style = Style.new()
78
+ self._frame_index = 0
79
+ self._inplace = inplace
80
+
81
+ def style(self, style: SpinnerStyle) -> Spinner:
82
+ """Sets the spinner style and returns the spinner."""
83
+ self._style = style
84
+ return self
85
+
86
+ def color(self, color: Color) -> Spinner:
87
+ """Sets the spinner color and returns the spinner."""
88
+ self._color = color
89
+ return self
90
+
91
+ def set_label(self, label: str) -> None:
92
+ """Updates the label shown next to the spinner glyph."""
93
+ self._label = label
94
+
95
+ def frame(self) -> Rendered:
96
+ """Builds the current frame as a single rendered line."""
97
+ frames = _SPINNER_FRAMES[self._style]
98
+ glyph = frames[self._frame_index % len(frames)]
99
+ return self._compose(glyph, Style.new().with_fg(self._color))
100
+
101
+ def tick(self) -> None:
102
+ """Advances to the next frame and redraws it in place."""
103
+ frame = self.frame()
104
+ self._ensure_inplace().draw(frame)
105
+ self._frame_index += 1
106
+
107
+ def clear(self) -> None:
108
+ """Stops the spinner and erases its line, leaving nothing behind."""
109
+ if self._inplace is not None:
110
+ self._inplace.clear()
111
+
112
+ def finish(self, *, success: bool, label: str) -> None:
113
+ """Stops the spinner with a success or failure marker."""
114
+ active = theme()
115
+ if success:
116
+ glyph = _SUCCESS_GLYPH[0] if active.unicode else _SUCCESS_GLYPH[1]
117
+ marker_style = active.success
118
+ else:
119
+ glyph = _FAILURE_GLYPH[0] if active.unicode else _FAILURE_GLYPH[1]
120
+ marker_style = active.error
121
+ self._label = label
122
+ frame = self._compose(glyph, marker_style)
123
+ inplace = self._ensure_inplace()
124
+ inplace.draw(frame)
125
+ inplace.finish()
126
+
127
+ def _compose(self, glyph: str, glyph_style: Style) -> Rendered:
128
+ """Composes a marker glyph and the label into one line."""
129
+ spans = [Span.styled(glyph, glyph_style)]
130
+ if self._label:
131
+ spans.append(Span.styled(f" {self._label}", self._label_style))
132
+ return Rendered([Line(spans)])
133
+
134
+ def _ensure_inplace(self) -> InPlace:
135
+ """Returns the in-place engine, creating a lazy one on first use."""
136
+ if self._inplace is None:
137
+ self._inplace = InPlace.create()
138
+ return self._inplace
@@ -0,0 +1,17 @@
1
+ """
2
+ sparcli.output.table
3
+ =====================
4
+
5
+ Data tables with headers, footers, borders, alignment and wrapping.
6
+
7
+ Re-exports the public table types. :class:`Table` composes :class:`Column`
8
+ definitions and :class:`Cell` values into a bordered grid that honors
9
+ per-column alignment, min/max/fixed widths, word wrap, zebra striping, a title,
10
+ footer rows and horizontal or vertical cell spanning.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from sparcli.output.table.table import Cell, Column, Table
16
+
17
+ __all__ = ["Cell", "Column", "Table"]
@@ -0,0 +1,198 @@
1
+ """
2
+ sparcli.output.table.plan
3
+ ==========================
4
+
5
+ Resolves table rows into a grid and computes each column's display width.
6
+
7
+ :func:`build_plan` walks the rows and places every cell at a concrete column,
8
+ honoring ``colspan`` and ``rowspan`` occupancy (rowspan continuation cells fill
9
+ the skipped slots in the rows below). :func:`column_widths` derives each
10
+ column's width from the header and body cells, clamps it by the column's
11
+ min/max/fixed constraints and, when the table overflows, shrinks the flexible
12
+ columns until it fits. The result feeds :mod:`sparcli.output.table.render`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from sparcli.output.table.table import Cell, Column, Row, TableLayout
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class PlacedCell:
27
+ """A cell placed at a concrete column, or a rowspan continuation slot."""
28
+
29
+ cell: Cell | None
30
+ start: int
31
+ colspan: int
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class RowPlan:
36
+ """One visual row: every column filled, including rowspan continuations."""
37
+
38
+ cells: list[PlacedCell]
39
+ footer: bool
40
+
41
+
42
+ def build_plan(rows: list[Row], cols: int) -> list[RowPlan]:
43
+ """
44
+ Resolves rows into a grid, honoring colspan and rowspan occupancy.
45
+
46
+ Parameters
47
+ ----------
48
+ rows : list[Row]
49
+ The table's rows in order.
50
+ cols : int
51
+ The number of columns defined on the table.
52
+
53
+ Returns
54
+ -------
55
+ list[RowPlan]
56
+ One plan per row, every column filled.
57
+ """
58
+ occupied = [0] * cols
59
+ plan: list[RowPlan] = []
60
+ for row in rows:
61
+ cells = iter(row.cells)
62
+ placed: list[PlacedCell] = []
63
+ col = 0
64
+ while col < cols:
65
+ if occupied[col] > 0:
66
+ occupied[col] -= 1
67
+ placed.append(PlacedCell(None, col, 1))
68
+ col += 1
69
+ continue
70
+ cell = next(cells, None)
71
+ if cell is None:
72
+ placed.append(PlacedCell(None, col, 1))
73
+ col += 1
74
+ continue
75
+ span = max(min(cell.span_cols, cols - col), 1)
76
+ if cell.span_rows > 1:
77
+ for slot in range(col, col + span):
78
+ occupied[slot] = cell.span_rows - 1
79
+ placed.append(PlacedCell(cell, col, span))
80
+ col += span
81
+ plan.append(RowPlan(placed, row.footer))
82
+ return plan
83
+
84
+
85
+ def column_widths(
86
+ layout: TableLayout, plan: list[RowPlan], max_width: int
87
+ ) -> list[int]:
88
+ """
89
+ Computes the display width of each column from the placement plan.
90
+
91
+ Parameters
92
+ ----------
93
+ layout : TableLayout
94
+ The column definitions and table options.
95
+ plan : list[RowPlan]
96
+ The resolved placement grid.
97
+ max_width : int
98
+ The maximum outer width in columns.
99
+
100
+ Returns
101
+ -------
102
+ list[int]
103
+ The width of each column, never exceeding ``max_width`` together.
104
+ """
105
+ columns = layout.columns
106
+ opts = layout.opts
107
+ widths = [0] * len(columns)
108
+ if opts.header:
109
+ for index, column in enumerate(columns):
110
+ widths[index] = column.header.width()
111
+ for row in plan:
112
+ for placed in row.cells:
113
+ if placed.cell is not None and placed.colspan == 1:
114
+ natural = placed.cell.content.width()
115
+ widths[placed.start] = max(widths[placed.start], natural)
116
+ for index, column in enumerate(columns):
117
+ widths[index] = _clamp_width(widths[index], column)
118
+ _Fitter(columns, widths, opts.pad).fit(max_width)
119
+ return widths
120
+
121
+
122
+ class ShrinkPass(enum.Enum):
123
+ """Which columns a shrink pass may take width from."""
124
+
125
+ WRAPPING = enum.auto()
126
+ NON_WRAPPING = enum.auto()
127
+
128
+
129
+ class _Fitter:
130
+ """Shrinks flexible columns until the table fits a maximum width."""
131
+
132
+ __slots__ = ("columns", "widths", "pad")
133
+
134
+ def __init__(
135
+ self, columns: list[Column], widths: list[int], pad: int
136
+ ) -> None:
137
+ self.columns = columns
138
+ self.widths = widths
139
+ self.pad = pad
140
+
141
+ def fit(self, max_width: int) -> None:
142
+ """Shrinks columns in place until the table fits ``max_width``."""
143
+ cells = len(self.widths)
144
+ overhead = cells * (2 * self.pad + 1) + 1
145
+ budget = max(max_width - overhead, 0)
146
+ content = sum(self.widths)
147
+ if content <= budget:
148
+ return
149
+ deficit = content - budget
150
+ deficit = self._shrink(deficit, ShrinkPass.WRAPPING)
151
+ self._shrink(deficit, ShrinkPass.NON_WRAPPING)
152
+
153
+ def _shrink(self, deficit: int, pass_: ShrinkPass) -> int:
154
+ """Takes one cell at a time from the widest eligible column."""
155
+ while deficit > 0:
156
+ index = self._widest_shrinkable(pass_)
157
+ if index is None:
158
+ break
159
+ self.widths[index] -= 1
160
+ deficit -= 1
161
+ return deficit
162
+
163
+ def _widest_shrinkable(self, pass_: ShrinkPass) -> int | None:
164
+ """Returns the eligible column with the most slack above its floor."""
165
+ best_index: int | None = None
166
+ best_width = 0
167
+ for index, width in enumerate(self.widths):
168
+ column = self.columns[index]
169
+ if not _eligible(column, pass_) or width <= _floor(column):
170
+ continue
171
+ if best_index is None or width > best_width:
172
+ best_index = index
173
+ best_width = width
174
+ return best_index
175
+
176
+
177
+ def _clamp_width(natural: int, column: Column) -> int:
178
+ """Clamps a natural width by a column's min/max/fixed constraints."""
179
+ if column.width_fixed is not None:
180
+ return column.width_fixed
181
+ width = max(natural, column.width_min)
182
+ if column.width_max is not None:
183
+ width = min(width, column.width_max)
184
+ return max(width, 1)
185
+
186
+
187
+ def _eligible(column: Column, pass_: ShrinkPass) -> bool:
188
+ """Returns whether ``column`` may give up width during ``pass_``."""
189
+ if column.width_fixed is not None:
190
+ return False
191
+ if pass_ is ShrinkPass.WRAPPING:
192
+ return column.wrapping
193
+ return not column.wrapping
194
+
195
+
196
+ def _floor(column: Column) -> int:
197
+ """Returns the narrowest a column may be shrunk to."""
198
+ return max(column.width_min, 1)
@@ -0,0 +1,302 @@
1
+ """
2
+ sparcli.output.table.render
3
+ ============================
4
+
5
+ Assembles the final rendered lines of a table.
6
+
7
+ The :class:`Builder` turns a :class:`~sparcli.output.table.table.TableLayout`,
8
+ its computed column widths and the placement grid from
9
+ :mod:`sparcli.output.table.plan` into styled lines: an optional centered title,
10
+ the top border, the header row and its separator, the body rows with zebra
11
+ striping and optional row separators, footer rows after a separator, and the
12
+ bottom border. Cells wrap when their column enables it, otherwise they truncate
13
+ with an ellipsis, and every horizontal edge chooses its corner and junction
14
+ glyphs from the border's :class:`~sparcli.core.border.BorderChars`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import enum
20
+ from dataclasses import dataclass, replace
21
+ from typing import TYPE_CHECKING
22
+
23
+ from sparcli.core.geometry import Align
24
+ from sparcli.core.render import Rendered
25
+ from sparcli.core.style import Style
26
+ from sparcli.core.text import Line, Span
27
+ from sparcli.core.width import truncate, wrap
28
+ from sparcli.output.layout import pad_line
29
+ from sparcli.output.table.plan import PlacedCell, RowPlan
30
+ from sparcli.output.table.table import Cell
31
+
32
+ if TYPE_CHECKING:
33
+ from sparcli.core.border import BorderChars
34
+ from sparcli.output.table.table import TableLayout, TableOpts
35
+
36
+
37
+ class Edge(enum.Enum):
38
+ """Which horizontal border line to draw."""
39
+
40
+ TOP = enum.auto()
41
+ MIDDLE = enum.auto()
42
+ BOTTOM = enum.auto()
43
+
44
+
45
+ # The (left, junction, right) glyph names of each horizontal edge.
46
+ _EDGE_GLYPHS: dict[Edge, tuple[str, str, str]] = {
47
+ Edge.TOP: ("top_left", "tee_down", "top_right"),
48
+ Edge.MIDDLE: ("tee_right", "cross", "tee_left"),
49
+ Edge.BOTTOM: ("bottom_left", "tee_up", "bottom_right"),
50
+ }
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class _Fmt:
55
+ """How a cell's content is fitted into its span width."""
56
+
57
+ width: int
58
+ style: Style
59
+ wrap: bool
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class _Placement:
64
+ """Where and how a cell's display line sits inside a row."""
65
+
66
+ width: int
67
+ pad: int
68
+ align: Align
69
+ fill: Style
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class _RowCtx:
74
+ """The placed cells and their display lines for one visual row."""
75
+
76
+ placed: list[PlacedCell]
77
+ cell_lines: list[list[Line]]
78
+ fill_bg: bool
79
+
80
+
81
+ class Builder:
82
+ """Assembles the table lines from the layout, plan and column widths."""
83
+
84
+ __slots__ = ("layout", "opts", "plan", "widths", "chars")
85
+
86
+ def __init__(
87
+ self, layout: TableLayout, plan: list[RowPlan], widths: list[int]
88
+ ) -> None:
89
+ self.layout = layout
90
+ self.opts: TableOpts = layout.opts
91
+ self.plan = plan
92
+ self.widths = widths
93
+ self.chars: BorderChars = layout.opts.border.chars()
94
+
95
+ def build(self) -> Rendered:
96
+ """Builds the full rendered table."""
97
+ lines: list[Line] = []
98
+ lines.extend(self._title_lines())
99
+ lines.extend(self._border(Edge.TOP))
100
+ if self.opts.header:
101
+ lines.extend(self._header_lines())
102
+ lines.extend(self._border(Edge.MIDDLE))
103
+ lines.extend(self._body_lines())
104
+ lines.extend(self._border(Edge.BOTTOM))
105
+ return Rendered(lines)
106
+
107
+ def _title_lines(self) -> list[Line]:
108
+ """Builds the optional centered title line above the table."""
109
+ title = self.opts.title
110
+ if title is None:
111
+ return []
112
+ first = title.lines[0] if title.lines else Line()
113
+ width = self._total_width()
114
+ return [pad_line(first, width, Align.CENTER, self.opts.title_style)]
115
+
116
+ def _header_lines(self) -> list[Line]:
117
+ """Builds the header row from the column headers."""
118
+ placed: list[PlacedCell] = []
119
+ for index, column in enumerate(self.layout.columns):
120
+ cell = Cell.new(column.header).align(column.alignment)
121
+ placed.append(PlacedCell(cell, index, 1))
122
+ return self._row_lines(placed, self.opts.header_style, False)
123
+
124
+ def _body_lines(self) -> list[Line]:
125
+ """Builds all body and footer rows from the placement plan."""
126
+ lines: list[Line] = []
127
+ last_body = self._last_body_index()
128
+ body_index = 0
129
+ footer_started = False
130
+ for index, row in enumerate(self.plan):
131
+ if row.footer and not footer_started:
132
+ lines.extend(self._border(Edge.MIDDLE))
133
+ footer_started = True
134
+ striped = self.opts.striped and body_index % 2 == 1
135
+ style = self.opts.stripe_style if striped else Style()
136
+ lines.extend(self._row_lines(row.cells, style, striped))
137
+ if row.footer:
138
+ continue
139
+ body_index += 1
140
+ if self.opts.row_separators and index != last_body:
141
+ lines.extend(self._border(Edge.MIDDLE))
142
+ return lines
143
+
144
+ def _last_body_index(self) -> int:
145
+ """Returns the index of the last non-footer row, or ``-1``."""
146
+ last = -1
147
+ for index, row in enumerate(self.plan):
148
+ if not row.footer:
149
+ last = index
150
+ return last
151
+
152
+ def _row_lines(
153
+ self, placed: list[PlacedCell], style: Style, fill_bg: bool
154
+ ) -> list[Line]:
155
+ """Renders one placed row into one or more physical lines."""
156
+ cell_lines = self._cell_lines(placed, style)
157
+ height = max((len(lines) for lines in cell_lines), default=1)
158
+ height = max(height, 1)
159
+ ctx = _RowCtx(placed, cell_lines, fill_bg)
160
+ return [self._row_line(ctx, physical) for physical in range(height)]
161
+
162
+ def _cell_lines(
163
+ self, placed: list[PlacedCell], style: Style
164
+ ) -> list[list[Line]]:
165
+ """Wraps or truncates each placed cell into its display lines."""
166
+ result: list[list[Line]] = []
167
+ for slot in placed:
168
+ if slot.cell is None:
169
+ result.append([Line()])
170
+ continue
171
+ width = self._span_width(slot.start, slot.colspan)
172
+ fmt = _Fmt(width, style, self._column_wraps(slot.start))
173
+ result.append(_format_cell(slot.cell, fmt))
174
+ return result
175
+
176
+ def _row_line(self, ctx: _RowCtx, physical: int) -> Line:
177
+ """Builds one physical line of a placed row."""
178
+ pad = self.opts.pad
179
+ spans: list[Span] = [self._vbar()]
180
+ for slot_index, slot in enumerate(ctx.placed):
181
+ width = self._span_width(slot.start, slot.colspan)
182
+ align = self._slot_align(slot)
183
+ content = _blank_or(ctx.cell_lines, slot_index, physical)
184
+ fill = self.opts.stripe_style if ctx.fill_bg else Style()
185
+ _push_cell(spans, content, _Placement(width, pad, align, fill))
186
+ spans.append(self._vbar())
187
+ return Line(spans)
188
+
189
+ def _slot_align(self, slot: PlacedCell) -> Align:
190
+ """Returns the effective alignment of a placed cell."""
191
+ if slot.cell is not None and slot.cell.alignment is not None:
192
+ return slot.cell.alignment
193
+ return self._align_for(slot.start)
194
+
195
+ def _align_for(self, index: int) -> Align:
196
+ """Returns the alignment of the column at ``index``."""
197
+ columns = self.layout.columns
198
+ if index < len(columns):
199
+ return columns[index].alignment
200
+ return Align.LEFT
201
+
202
+ def _column_wraps(self, index: int) -> bool:
203
+ """Returns whether the column at ``index`` wraps its content."""
204
+ columns = self.layout.columns
205
+ return index < len(columns) and columns[index].wrapping
206
+
207
+ def _span_width(self, start: int, colspan: int) -> int:
208
+ """Returns the width spanned by ``colspan`` columns from ``start``."""
209
+ pad = self.opts.pad
210
+ end = min(start + colspan, len(self.widths))
211
+ content = sum(self.widths[start:end])
212
+ extra = max(colspan - 1, 0) * (2 * pad + 1)
213
+ return content + extra
214
+
215
+ def _total_width(self) -> int:
216
+ """Returns the total outer width of the table."""
217
+ pad = self.opts.pad
218
+ cells = len(self.widths)
219
+ return sum(self.widths) + cells * 2 * pad + cells + 1
220
+
221
+ def _vbar(self) -> Span:
222
+ """Returns a vertical border span."""
223
+ return Span.styled(self.chars.vertical, self.opts.border_style)
224
+
225
+ def _border(self, edge: Edge) -> list[Line]:
226
+ """Builds a horizontal border line for ``edge``, or nothing."""
227
+ if self.opts.border.is_none():
228
+ return []
229
+ left, mid, right = self._corners(edge)
230
+ pad = self.opts.pad
231
+ horizontal = self.chars.horizontal
232
+ parts = [left]
233
+ count = len(self.widths)
234
+ for index, width in enumerate(self.widths):
235
+ parts.append(horizontal * (width + 2 * pad))
236
+ if index + 1 < count:
237
+ parts.append(mid)
238
+ parts.append(right)
239
+ return [Line.styled("".join(parts), self.opts.border_style)]
240
+
241
+ def _corners(self, edge: Edge) -> tuple[str, str, str]:
242
+ """Returns the (left, junction, right) glyphs for ``edge``."""
243
+ left, mid, right = _EDGE_GLYPHS[edge]
244
+ chars = self.chars
245
+ return (
246
+ getattr(chars, left),
247
+ getattr(chars, mid),
248
+ getattr(chars, right),
249
+ )
250
+
251
+
252
+ def _format_cell(cell: Cell, fmt: _Fmt) -> list[Line]:
253
+ """Formats one cell into styled lines, wrapping or truncating overflow."""
254
+ out: list[Line] = []
255
+ for line in cell.content.lines:
256
+ plain = line.plain()
257
+ if line.width() <= fmt.width:
258
+ out.append(_restyle(line, fmt.style))
259
+ elif fmt.wrap:
260
+ for chunk in wrap(plain, fmt.width):
261
+ out.append(Line.styled(chunk, fmt.style))
262
+ else:
263
+ span_style = line.spans[0].style if line.spans else fmt.style
264
+ cell_style = fmt.style.patch(span_style)
265
+ out.append(Line.styled(truncate(plain, fmt.width, "…"), cell_style))
266
+ if not out:
267
+ out.append(Line())
268
+ return out
269
+
270
+
271
+ def _restyle(line: Line, base: Style) -> Line:
272
+ """Applies a base style underneath each span's own style."""
273
+ spans = [replace(span, style=base.patch(span.style)) for span in line.spans]
274
+ return Line(spans)
275
+
276
+
277
+ def _blank_or(cell_lines: list[list[Line]], index: int, physical: int) -> Line:
278
+ """Returns the cell's display line at ``physical``, or a blank line."""
279
+ if index < len(cell_lines):
280
+ lines = cell_lines[index]
281
+ if physical < len(lines):
282
+ return lines[physical]
283
+ return Line()
284
+
285
+
286
+ def _push_cell(spans: list[Span], content: Line, place: _Placement) -> None:
287
+ """Pushes one padded, aligned cell into ``spans``."""
288
+ truncated = _clip(content, place.width)
289
+ padded = pad_line(truncated, place.width, place.align, place.fill)
290
+ if place.pad > 0:
291
+ spans.append(Span.styled(" " * place.pad, place.fill))
292
+ spans.extend(padded.spans)
293
+ if place.pad > 0:
294
+ spans.append(Span.styled(" " * place.pad, place.fill))
295
+
296
+
297
+ def _clip(line: Line, width: int) -> Line:
298
+ """Truncates a line to ``width`` columns, keeping the first span's style."""
299
+ if line.width() <= width:
300
+ return line
301
+ style = line.spans[0].style if line.spans else Style()
302
+ return Line.styled(truncate(line.plain(), width, "…"), style)