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.
- py_sparcli-0.3.0.dist-info/METADATA +257 -0
- py_sparcli-0.3.0.dist-info/RECORD +61 -0
- py_sparcli-0.3.0.dist-info/WHEEL +4 -0
- py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- sparcli/__init__.py +184 -0
- sparcli/core/__init__.py +92 -0
- sparcli/core/border.py +160 -0
- sparcli/core/color.py +306 -0
- sparcli/core/cursor.py +59 -0
- sparcli/core/geometry.py +141 -0
- sparcli/core/markup.py +215 -0
- sparcli/core/render.py +245 -0
- sparcli/core/style.py +128 -0
- sparcli/core/terminal.py +127 -0
- sparcli/core/text.py +143 -0
- sparcli/core/theme.py +117 -0
- sparcli/core/width.py +244 -0
- sparcli/errors.py +38 -0
- sparcli/input/__init__.py +42 -0
- sparcli/input/calc.py +214 -0
- sparcli/input/confirm.py +188 -0
- sparcli/input/datepicker.py +298 -0
- sparcli/input/editor.py +189 -0
- sparcli/input/event.py +551 -0
- sparcli/input/field.py +90 -0
- sparcli/input/fuzzy.py +401 -0
- sparcli/input/guard.py +103 -0
- sparcli/input/history.py +160 -0
- sparcli/input/line_edit.py +229 -0
- sparcli/input/number.py +232 -0
- sparcli/input/outcome.py +107 -0
- sparcli/input/password.py +233 -0
- sparcli/input/prompt.py +134 -0
- sparcli/input/select.py +336 -0
- sparcli/input/shortcut.py +123 -0
- sparcli/input/text.py +564 -0
- sparcli/input/textarea.py +242 -0
- sparcli/input/validate.py +67 -0
- sparcli/output/__init__.py +56 -0
- sparcli/output/alert.py +107 -0
- sparcli/output/badge.py +82 -0
- sparcli/output/box.py +196 -0
- sparcli/output/columns.py +157 -0
- sparcli/output/compose.py +111 -0
- sparcli/output/diff.py +229 -0
- sparcli/output/kv.py +175 -0
- sparcli/output/layout.py +77 -0
- sparcli/output/list.py +274 -0
- sparcli/output/live.py +129 -0
- sparcli/output/multiprogress.py +73 -0
- sparcli/output/pager.py +83 -0
- sparcli/output/panel.py +125 -0
- sparcli/output/progress.py +233 -0
- sparcli/output/rule.py +134 -0
- sparcli/output/spinner.py +138 -0
- sparcli/output/table/__init__.py +17 -0
- sparcli/output/table/plan.py +198 -0
- sparcli/output/table/render.py +302 -0
- sparcli/output/table/table.py +351 -0
- sparcli/output/tree.py +157 -0
- 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)
|