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
sparcli/output/box.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.box
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Shared box-drawing routine used by :class:`~sparcli.output.panel.Panel` and
|
|
6
|
+
:class:`~sparcli.output.alert.Alert`.
|
|
7
|
+
|
|
8
|
+
:func:`draw_box` frames a rendered block with a border, padding and an optional
|
|
9
|
+
fill, embedding a title in the top edge and a subtitle in the bottom edge. A
|
|
10
|
+
``BorderType.NONE`` box degrades to padding only. The title-embedding rules
|
|
11
|
+
match the Rust original: a left-aligned title keeps a single connecting glyph.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from sparcli.core.border import BorderType
|
|
19
|
+
from sparcli.core.geometry import Align, Edges, Title
|
|
20
|
+
from sparcli.core.render import Rendered
|
|
21
|
+
from sparcli.core.style import Style
|
|
22
|
+
from sparcli.core.text import Line, Span
|
|
23
|
+
from sparcli.core.width import truncate, visible_width
|
|
24
|
+
from sparcli.output.layout import blank_line, pad_line, space_span
|
|
25
|
+
|
|
26
|
+
_BORDER_COLUMNS = 2
|
|
27
|
+
_LEFT_CONNECTOR = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class BoxOpts:
|
|
32
|
+
"""Options controlling how :func:`draw_box` frames its content."""
|
|
33
|
+
|
|
34
|
+
border: BorderType = BorderType.ROUNDED
|
|
35
|
+
border_style: Style = field(default_factory=Style)
|
|
36
|
+
fill: Style = field(default_factory=Style)
|
|
37
|
+
padding: Edges = field(default_factory=lambda: Edges.symmetric(0, 1))
|
|
38
|
+
title: Title | None = None
|
|
39
|
+
subtitle: Title | None = None
|
|
40
|
+
width: int | None = None
|
|
41
|
+
content_align: Align = Align.LEFT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def draw_box(content: Rendered, opts: BoxOpts, max_width: int) -> Rendered:
|
|
45
|
+
"""
|
|
46
|
+
Frames ``content`` with a border, padding and fill.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
content : Rendered
|
|
51
|
+
The block to frame.
|
|
52
|
+
opts : BoxOpts
|
|
53
|
+
The framing options.
|
|
54
|
+
max_width : int
|
|
55
|
+
The maximum outer width in columns.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
Rendered
|
|
60
|
+
The framed block.
|
|
61
|
+
"""
|
|
62
|
+
if opts.border.is_none():
|
|
63
|
+
return _frame_borderless(content, opts, max_width)
|
|
64
|
+
chars = opts.border.chars()
|
|
65
|
+
inner_content = _inner_content_width(content, opts, max_width)
|
|
66
|
+
body = _body_rows(content, inner_content, opts)
|
|
67
|
+
inner_width = opts.padding.left + inner_content + opts.padding.right
|
|
68
|
+
lines = [
|
|
69
|
+
_edge_line(chars.top_left, chars.top_right, inner_width, opts, True)
|
|
70
|
+
]
|
|
71
|
+
lines.extend(
|
|
72
|
+
_border_row(row, chars.vertical, opts.border_style) for row in body
|
|
73
|
+
)
|
|
74
|
+
lines.append(
|
|
75
|
+
_edge_line(
|
|
76
|
+
chars.bottom_left, chars.bottom_right, inner_width, opts, False
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return Rendered(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _frame_borderless(
|
|
83
|
+
content: Rendered, opts: BoxOpts, max_width: int
|
|
84
|
+
) -> Rendered:
|
|
85
|
+
"""Frames content with padding and fill only, without border glyphs."""
|
|
86
|
+
inner_content = _inner_content_width(content, opts, max_width)
|
|
87
|
+
return Rendered(_body_rows(content, inner_content, opts))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _inner_content_width(
|
|
91
|
+
content: Rendered, opts: BoxOpts, max_width: int
|
|
92
|
+
) -> int:
|
|
93
|
+
"""Computes the width available for content between the padding."""
|
|
94
|
+
border_cols = 0 if opts.border.is_none() else _BORDER_COLUMNS
|
|
95
|
+
overhead = border_cols + opts.padding.horizontal()
|
|
96
|
+
if opts.width is not None:
|
|
97
|
+
outer = min(opts.width, max_width)
|
|
98
|
+
return max(0, outer - overhead)
|
|
99
|
+
natural = content.width()
|
|
100
|
+
if natural + overhead <= max_width:
|
|
101
|
+
return natural
|
|
102
|
+
return max(0, max_width - overhead)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _body_rows(
|
|
106
|
+
content: Rendered, inner_content: int, opts: BoxOpts
|
|
107
|
+
) -> list[Line]:
|
|
108
|
+
"""Builds the padded, filled content rows between the border edges."""
|
|
109
|
+
inner_width = opts.padding.left + inner_content + opts.padding.right
|
|
110
|
+
rows: list[Line] = []
|
|
111
|
+
for _ in range(opts.padding.top):
|
|
112
|
+
rows.append(blank_line(inner_width, opts.fill))
|
|
113
|
+
for line in content.lines:
|
|
114
|
+
filled = pad_line(line, inner_content, opts.content_align, opts.fill)
|
|
115
|
+
rows.append(_content_row(filled, opts))
|
|
116
|
+
for _ in range(opts.padding.bottom):
|
|
117
|
+
rows.append(blank_line(inner_width, opts.fill))
|
|
118
|
+
return rows
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _content_row(line: Line, opts: BoxOpts) -> Line:
|
|
122
|
+
"""Wraps an aligned content line in horizontal padding."""
|
|
123
|
+
spans: list[Span] = []
|
|
124
|
+
if opts.padding.left:
|
|
125
|
+
spans.append(space_span(opts.padding.left, opts.fill))
|
|
126
|
+
spans.extend(line.spans)
|
|
127
|
+
if opts.padding.right:
|
|
128
|
+
spans.append(space_span(opts.padding.right, opts.fill))
|
|
129
|
+
return Line(spans)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _border_row(row: Line, vertical: str, style: Style) -> Line:
|
|
133
|
+
"""Wraps a content row with the left and right vertical border glyphs."""
|
|
134
|
+
edge = Span.styled(vertical, style)
|
|
135
|
+
return Line([edge, *row.spans, edge])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _edge_line(
|
|
139
|
+
left: str, right: str, inner_width: int, opts: BoxOpts, is_top: bool
|
|
140
|
+
) -> Line:
|
|
141
|
+
"""Builds a top or bottom border edge, embedding the title if present."""
|
|
142
|
+
title = opts.title if is_top else opts.subtitle
|
|
143
|
+
corner_left = Span.styled(left, opts.border_style)
|
|
144
|
+
corner_right = Span.styled(right, opts.border_style)
|
|
145
|
+
horizontal = opts.border.chars().horizontal
|
|
146
|
+
if title is None or not title.content.lines:
|
|
147
|
+
run = Span.styled(horizontal * inner_width, opts.border_style)
|
|
148
|
+
return Line([corner_left, run, corner_right])
|
|
149
|
+
middle = _embed_title(title, inner_width, horizontal, opts.border_style)
|
|
150
|
+
return Line([corner_left, *middle, corner_right])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _embed_title(
|
|
154
|
+
title: Title, width: int, horizontal: str, style: Style
|
|
155
|
+
) -> list[Span]:
|
|
156
|
+
"""Embeds a title in a horizontal border run of ``width`` columns."""
|
|
157
|
+
label = _title_spans(title)
|
|
158
|
+
label_width = sum(span.width() for span in label) + 2 * title.pad
|
|
159
|
+
if label_width >= width:
|
|
160
|
+
text = truncate(title.content.lines[0].plain(), width)
|
|
161
|
+
return [Span.styled(text, style)]
|
|
162
|
+
left_run, right_run = _title_runs(title.align, width, label_width)
|
|
163
|
+
pad_span = space_span(title.pad, style)
|
|
164
|
+
spans: list[Span] = []
|
|
165
|
+
if left_run:
|
|
166
|
+
spans.append(Span.styled(horizontal * left_run, style))
|
|
167
|
+
spans.append(pad_span)
|
|
168
|
+
spans.extend(label)
|
|
169
|
+
spans.append(pad_span)
|
|
170
|
+
if right_run:
|
|
171
|
+
spans.append(Span.styled(horizontal * right_run, style))
|
|
172
|
+
return spans
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _title_spans(title: Title) -> list[Span]:
|
|
176
|
+
"""Returns the styled spans of a title's first line."""
|
|
177
|
+
if not title.content.lines:
|
|
178
|
+
return []
|
|
179
|
+
return list(title.content.lines[0].spans)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _title_runs(align: Align, width: int, label_width: int) -> tuple[int, int]:
|
|
183
|
+
"""Splits the border run around a title according to its alignment."""
|
|
184
|
+
slack = width - label_width
|
|
185
|
+
if align is Align.LEFT:
|
|
186
|
+
return (_LEFT_CONNECTOR, slack - _LEFT_CONNECTOR)
|
|
187
|
+
if align is Align.RIGHT:
|
|
188
|
+
return (slack - _LEFT_CONNECTOR, _LEFT_CONNECTOR)
|
|
189
|
+
left = slack // 2
|
|
190
|
+
return (left, slack - left)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def title_bar_width(title: Title) -> int:
|
|
194
|
+
"""Returns the column width a title occupies including its padding."""
|
|
195
|
+
label = sum(visible_width(line.plain()) for line in title.content.lines[:1])
|
|
196
|
+
return label + 2 * title.pad
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.columns
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Columns`, a side-by-side arrangement of rendered blocks.
|
|
6
|
+
|
|
7
|
+
Each column is laid out at its own width, padded to the tallest column and
|
|
8
|
+
placed next to its neighbours with a configurable gap. Shorter columns are
|
|
9
|
+
vertically aligned (top, middle or bottom), and an optional border glyph can be
|
|
10
|
+
drawn as a separator centered in the gap. Both a keyword constructor and fluent
|
|
11
|
+
builders are provided.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from sparcli.core.border import BorderType
|
|
19
|
+
from sparcli.core.geometry import Align, VAlign
|
|
20
|
+
from sparcli.core.render import Renderable, Rendered
|
|
21
|
+
from sparcli.core.style import Style
|
|
22
|
+
from sparcli.core.text import Line, Span
|
|
23
|
+
from sparcli.core.theme import theme
|
|
24
|
+
from sparcli.output.layout import blank_line, pad_line
|
|
25
|
+
|
|
26
|
+
_DEFAULT_GAP = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class _ColumnItem:
|
|
31
|
+
"""One column: a rendered block and its horizontal alignment."""
|
|
32
|
+
|
|
33
|
+
block: Rendered
|
|
34
|
+
align: Align = Align.LEFT
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Columns(Renderable):
|
|
38
|
+
"""A horizontal arrangement of rendered blocks."""
|
|
39
|
+
|
|
40
|
+
__slots__ = (
|
|
41
|
+
"_items",
|
|
42
|
+
"_gap",
|
|
43
|
+
"_separator",
|
|
44
|
+
"_separator_style",
|
|
45
|
+
"_valign",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
gap: int = _DEFAULT_GAP,
|
|
52
|
+
separator: BorderType | None = None,
|
|
53
|
+
separator_style: Style | None = None,
|
|
54
|
+
valign: VAlign = VAlign.TOP,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._items: list[_ColumnItem] = []
|
|
57
|
+
self._gap = gap
|
|
58
|
+
self._separator = separator
|
|
59
|
+
self._separator_style = (
|
|
60
|
+
separator_style
|
|
61
|
+
if separator_style is not None
|
|
62
|
+
else theme().secondary
|
|
63
|
+
)
|
|
64
|
+
self._valign = valign
|
|
65
|
+
|
|
66
|
+
def add(self, content: Renderable, width: int) -> Columns:
|
|
67
|
+
"""Adds a column from a renderable laid out at ``width`` columns."""
|
|
68
|
+
self._items.append(_ColumnItem(block=content.render(width)))
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def add_rendered(self, block: Rendered) -> Columns:
|
|
72
|
+
"""Adds a column from an already rendered block and returns self."""
|
|
73
|
+
self._items.append(_ColumnItem(block=block))
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def align(self, align: Align) -> Columns:
|
|
77
|
+
"""Sets the alignment of the most recently added column."""
|
|
78
|
+
if self._items:
|
|
79
|
+
self._items[-1].align = align
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def gap(self, gap: int) -> Columns:
|
|
83
|
+
"""Sets the gap between columns in spaces and returns self."""
|
|
84
|
+
self._gap = gap
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def separator(self, border: BorderType) -> Columns:
|
|
88
|
+
"""Draws a vertical separator between columns and returns self."""
|
|
89
|
+
self._separator = border
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def valign(self, valign: VAlign) -> Columns:
|
|
93
|
+
"""Sets the vertical alignment of shorter columns and returns self."""
|
|
94
|
+
self._valign = valign
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def render(self, max_width: int) -> Rendered:
|
|
98
|
+
"""Renders the columns side by side, ignoring ``max_width``."""
|
|
99
|
+
if not self._items:
|
|
100
|
+
return Rendered.empty()
|
|
101
|
+
height = max(item.block.height() for item in self._items)
|
|
102
|
+
widths = [item.block.width() for item in self._items]
|
|
103
|
+
aligned = [
|
|
104
|
+
self._align_column(item, width, height)
|
|
105
|
+
for item, width in zip(self._items, widths, strict=True)
|
|
106
|
+
]
|
|
107
|
+
lines = [
|
|
108
|
+
self._compose_row(aligned, widths, row) for row in range(height)
|
|
109
|
+
]
|
|
110
|
+
return Rendered(lines)
|
|
111
|
+
|
|
112
|
+
def _align_column(
|
|
113
|
+
self, item: _ColumnItem, width: int, height: int
|
|
114
|
+
) -> list[Line]:
|
|
115
|
+
"""Vertically aligns and pads a column into a uniform block."""
|
|
116
|
+
pad_top = _pad_top(self._valign, height, item.block.height())
|
|
117
|
+
lines: list[Line] = [blank_line(width) for _ in range(pad_top)]
|
|
118
|
+
for line in item.block.lines:
|
|
119
|
+
lines.append(pad_line(line, width, item.align))
|
|
120
|
+
while len(lines) < height:
|
|
121
|
+
lines.append(blank_line(width))
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
def _compose_row(
|
|
125
|
+
self, aligned: list[list[Line]], widths: list[int], row: int
|
|
126
|
+
) -> Line:
|
|
127
|
+
"""Joins the columns at index ``row`` into one output line."""
|
|
128
|
+
spans: list[Span] = []
|
|
129
|
+
for index, column in enumerate(aligned):
|
|
130
|
+
if index > 0:
|
|
131
|
+
self._push_gap(spans)
|
|
132
|
+
line = (
|
|
133
|
+
column[row] if row < len(column) else blank_line(widths[index])
|
|
134
|
+
)
|
|
135
|
+
spans.extend(line.spans)
|
|
136
|
+
return Line(spans)
|
|
137
|
+
|
|
138
|
+
def _push_gap(self, spans: list[Span]) -> None:
|
|
139
|
+
"""Pushes the inter-column gap and optional separator glyph."""
|
|
140
|
+
if self._separator is None:
|
|
141
|
+
spans.append(Span.raw(" " * self._gap))
|
|
142
|
+
return
|
|
143
|
+
half = self._gap // 2
|
|
144
|
+
glyph = self._separator.chars().vertical
|
|
145
|
+
spans.append(Span.raw(" " * half))
|
|
146
|
+
spans.append(Span.styled(glyph, self._separator_style))
|
|
147
|
+
rest = max(0, self._gap - (half + 1))
|
|
148
|
+
spans.append(Span.raw(" " * rest))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pad_top(valign: VAlign, height: int, block_height: int) -> int:
|
|
152
|
+
"""Returns the number of blank rows above a column for a vertical align."""
|
|
153
|
+
if valign is VAlign.TOP:
|
|
154
|
+
return 0
|
|
155
|
+
if valign is VAlign.MIDDLE:
|
|
156
|
+
return (height - block_height) // 2
|
|
157
|
+
return height - block_height
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.compose
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Composition helpers for stacking and placing rendered blocks.
|
|
6
|
+
|
|
7
|
+
:func:`align` pads a block to a width, :func:`pad` adds box-model spacing, and
|
|
8
|
+
:func:`vstack` stacks blocks vertically with an optional gap. They operate on
|
|
9
|
+
:class:`~sparcli.core.render.Rendered` values, so widgets and hand-built blocks
|
|
10
|
+
compose the same way.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from sparcli.core.geometry import Align, Edges
|
|
18
|
+
from sparcli.core.render import Rendered
|
|
19
|
+
from sparcli.core.style import Style
|
|
20
|
+
from sparcli.core.text import Line, Span
|
|
21
|
+
from sparcli.output.layout import blank_line, pad_line, space_span
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def align(rendered: Rendered, width: int, alignment: Align) -> Rendered:
|
|
25
|
+
"""
|
|
26
|
+
Pads every line of ``rendered`` to ``width`` using ``alignment``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
rendered : Rendered
|
|
31
|
+
The block to align.
|
|
32
|
+
width : int
|
|
33
|
+
The target width in columns.
|
|
34
|
+
alignment : Align
|
|
35
|
+
How to place each line within the width.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
Rendered
|
|
40
|
+
A new block with every line padded to ``width``.
|
|
41
|
+
"""
|
|
42
|
+
return Rendered(
|
|
43
|
+
[pad_line(line, width, alignment) for line in rendered.lines]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pad(
|
|
48
|
+
rendered: Rendered, edges: Edges, style: Style | None = None
|
|
49
|
+
) -> Rendered:
|
|
50
|
+
"""
|
|
51
|
+
Surrounds ``rendered`` with box-model spacing.
|
|
52
|
+
|
|
53
|
+
A content line is only padded up to the block width when a right margin
|
|
54
|
+
must align; with no right margin it is kept as-is, so shorter lines stay
|
|
55
|
+
ragged instead of gaining trailing spaces.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
rendered : Rendered
|
|
60
|
+
The block to pad.
|
|
61
|
+
edges : Edges
|
|
62
|
+
The spacing to add on each side.
|
|
63
|
+
style : Style | None
|
|
64
|
+
The fill style for the padding.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
Rendered
|
|
69
|
+
A new padded block.
|
|
70
|
+
"""
|
|
71
|
+
inner_width = rendered.width()
|
|
72
|
+
total_width = inner_width + edges.horizontal()
|
|
73
|
+
lines: list[Line] = []
|
|
74
|
+
for _ in range(edges.top):
|
|
75
|
+
lines.append(blank_line(total_width, style))
|
|
76
|
+
for line in rendered.lines:
|
|
77
|
+
spans: list[Span] = []
|
|
78
|
+
if edges.left:
|
|
79
|
+
spans.append(space_span(edges.left, style))
|
|
80
|
+
spans.extend(line.spans)
|
|
81
|
+
if edges.right:
|
|
82
|
+
gap = inner_width - line.width() + edges.right
|
|
83
|
+
spans.append(space_span(gap, style))
|
|
84
|
+
lines.append(Line(spans))
|
|
85
|
+
for _ in range(edges.bottom):
|
|
86
|
+
lines.append(blank_line(total_width, style))
|
|
87
|
+
return Rendered(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def vstack(blocks: Sequence[Rendered], gap: int = 0) -> Rendered:
|
|
91
|
+
"""
|
|
92
|
+
Stacks blocks vertically with ``gap`` blank lines between them.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
blocks : Sequence[Rendered]
|
|
97
|
+
The blocks to stack, top to bottom.
|
|
98
|
+
gap : int
|
|
99
|
+
The number of blank lines inserted between blocks.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
Rendered
|
|
104
|
+
The stacked block.
|
|
105
|
+
"""
|
|
106
|
+
lines: list[Line] = []
|
|
107
|
+
for index, block in enumerate(blocks):
|
|
108
|
+
if index and gap:
|
|
109
|
+
lines.extend(blank_line() for _ in range(gap))
|
|
110
|
+
lines.extend(block.lines)
|
|
111
|
+
return Rendered(lines)
|
sparcli/output/diff.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.output.diff
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines :class:`Diff`, a colored unified diff between two texts.
|
|
6
|
+
|
|
7
|
+
Lines are matched with a longest-common-subsequence edit script and emitted as
|
|
8
|
+
equal (`` ``), deleted (``- ``) or inserted (``+ ``) rows. Unchanged regions
|
|
9
|
+
beyond a context window collapse into a ``…`` hunk marker, and a ``--- old`` /
|
|
10
|
+
``+++ new`` header precedes the body unless it is disabled. Very large inputs
|
|
11
|
+
fall back to a plain delete-then-insert listing.
|
|
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 Renderable, Rendered
|
|
20
|
+
from sparcli.core.style import Style
|
|
21
|
+
from sparcli.core.text import Line
|
|
22
|
+
from sparcli.core.theme import theme
|
|
23
|
+
|
|
24
|
+
# Default number of unchanged context lines kept around each change.
|
|
25
|
+
DEFAULT_CONTEXT = 3
|
|
26
|
+
# Maximum lines per side before falling back to a full listing.
|
|
27
|
+
MAX_DIFF_LINES = 5000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _OpKind(enum.Enum):
|
|
31
|
+
"""The kind of a single line operation in the edit script."""
|
|
32
|
+
|
|
33
|
+
EQUAL = enum.auto()
|
|
34
|
+
DELETE = enum.auto()
|
|
35
|
+
INSERT = enum.auto()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The two-character gutter prefix for each operation kind.
|
|
39
|
+
_OP_PREFIX: dict[_OpKind, str] = {
|
|
40
|
+
_OpKind.EQUAL: " ",
|
|
41
|
+
_OpKind.DELETE: "- ",
|
|
42
|
+
_OpKind.INSERT: "+ ",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Op:
|
|
47
|
+
"""A single line operation carrying its kind and text."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("kind", "text")
|
|
50
|
+
|
|
51
|
+
def __init__(self, kind: _OpKind, text: str) -> None:
|
|
52
|
+
self.kind = kind
|
|
53
|
+
self.text = text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Diff(Renderable):
|
|
57
|
+
"""A unified, colored diff between an ``old`` and a ``new`` text."""
|
|
58
|
+
|
|
59
|
+
__slots__ = (
|
|
60
|
+
"_old",
|
|
61
|
+
"_new",
|
|
62
|
+
"_context",
|
|
63
|
+
"_no_header",
|
|
64
|
+
"_old_label",
|
|
65
|
+
"_new_label",
|
|
66
|
+
"_add_style",
|
|
67
|
+
"_del_style",
|
|
68
|
+
"_hunk_style",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
old: str,
|
|
74
|
+
new: str,
|
|
75
|
+
*,
|
|
76
|
+
context: int = DEFAULT_CONTEXT,
|
|
77
|
+
no_header: bool = False,
|
|
78
|
+
old_label: str = "old",
|
|
79
|
+
new_label: str = "new",
|
|
80
|
+
add_style: Style | None = None,
|
|
81
|
+
del_style: Style | None = None,
|
|
82
|
+
hunk_style: Style | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._old = old
|
|
85
|
+
self._new = new
|
|
86
|
+
self._context = context
|
|
87
|
+
self._no_header = no_header
|
|
88
|
+
self._old_label = old_label
|
|
89
|
+
self._new_label = new_label
|
|
90
|
+
self._add_style = (
|
|
91
|
+
add_style
|
|
92
|
+
if add_style is not None
|
|
93
|
+
else Style.from_color(Color.GREEN)
|
|
94
|
+
)
|
|
95
|
+
self._del_style = (
|
|
96
|
+
del_style if del_style is not None else Style.from_color(Color.RED)
|
|
97
|
+
)
|
|
98
|
+
self._hunk_style = (
|
|
99
|
+
hunk_style if hunk_style is not None else theme().secondary
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def context(self, lines: int) -> Diff:
|
|
103
|
+
"""Sets the number of context lines around changes."""
|
|
104
|
+
self._context = lines
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def no_header(self) -> Diff:
|
|
108
|
+
"""Hides the ``---``/``+++`` header and returns the diff."""
|
|
109
|
+
self._no_header = True
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def labels(self, old: str, new: str) -> Diff:
|
|
113
|
+
"""Sets the old and new side labels and returns the diff."""
|
|
114
|
+
self._old_label = old
|
|
115
|
+
self._new_label = new
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def render(self, max_width: int) -> Rendered:
|
|
119
|
+
"""Renders the diff; ``max_width`` is accepted but not constraining."""
|
|
120
|
+
del max_width
|
|
121
|
+
old = _split_lines(self._old)
|
|
122
|
+
new = _split_lines(self._new)
|
|
123
|
+
ops = _diff_ops(old, new)
|
|
124
|
+
lines: list[Line] = []
|
|
125
|
+
if not self._no_header:
|
|
126
|
+
self._push_header(lines)
|
|
127
|
+
self._push_ops(lines, ops)
|
|
128
|
+
return Rendered(lines)
|
|
129
|
+
|
|
130
|
+
def _push_header(self, lines: list[Line]) -> None:
|
|
131
|
+
"""Pushes the ``--- old`` / ``+++ new`` header lines."""
|
|
132
|
+
lines.append(Line.styled(f"--- {self._old_label}", self._del_style))
|
|
133
|
+
lines.append(Line.styled(f"+++ {self._new_label}", self._add_style))
|
|
134
|
+
|
|
135
|
+
def _push_ops(self, lines: list[Line], ops: list[_Op]) -> None:
|
|
136
|
+
"""Pushes the diff body, collapsing unchanged regions to context."""
|
|
137
|
+
visible = _mark_visible(ops, self._context)
|
|
138
|
+
last_visible = False
|
|
139
|
+
for index, op in enumerate(ops):
|
|
140
|
+
if not visible[index]:
|
|
141
|
+
last_visible = False
|
|
142
|
+
continue
|
|
143
|
+
if not last_visible and index > 0:
|
|
144
|
+
lines.append(Line.styled("…", self._hunk_style))
|
|
145
|
+
lines.append(self._format_op(op))
|
|
146
|
+
last_visible = True
|
|
147
|
+
|
|
148
|
+
def _format_op(self, op: _Op) -> Line:
|
|
149
|
+
"""Formats a single operation as a styled line."""
|
|
150
|
+
text = f"{_OP_PREFIX[op.kind]}{op.text}"
|
|
151
|
+
if op.kind is _OpKind.EQUAL:
|
|
152
|
+
return Line.raw(text)
|
|
153
|
+
style = (
|
|
154
|
+
self._del_style if op.kind is _OpKind.DELETE else self._add_style
|
|
155
|
+
)
|
|
156
|
+
return Line.styled(text, style)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _split_lines(text: str) -> list[str]:
|
|
160
|
+
"""Splits ``text`` into lines the way Rust's ``str::lines`` does."""
|
|
161
|
+
if not text:
|
|
162
|
+
return []
|
|
163
|
+
parts = text.split("\n")
|
|
164
|
+
if parts and parts[-1] == "":
|
|
165
|
+
parts.pop()
|
|
166
|
+
return [part.removesuffix("\r") for part in parts]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _mark_visible(ops: list[_Op], context: int) -> list[bool]:
|
|
170
|
+
"""Marks which ops are visible given the surrounding context window."""
|
|
171
|
+
visible = [False] * len(ops)
|
|
172
|
+
for index, op in enumerate(ops):
|
|
173
|
+
if op.kind is _OpKind.EQUAL:
|
|
174
|
+
continue
|
|
175
|
+
start = max(0, index - context)
|
|
176
|
+
end = min(index + context + 1, len(ops))
|
|
177
|
+
for cursor in range(start, end):
|
|
178
|
+
visible[cursor] = True
|
|
179
|
+
return visible
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _diff_ops(old: list[str], new: list[str]) -> list[_Op]:
|
|
183
|
+
"""Computes a line-based edit script using an LCS table."""
|
|
184
|
+
if len(old) > MAX_DIFF_LINES or len(new) > MAX_DIFF_LINES:
|
|
185
|
+
return _fallback_ops(old, new)
|
|
186
|
+
table = _lcs_table(old, new)
|
|
187
|
+
return _backtrack(table, old, new)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _lcs_table(old: list[str], new: list[str]) -> list[list[int]]:
|
|
191
|
+
"""Builds the longest-common-subsequence length table."""
|
|
192
|
+
table = [[0] * (len(new) + 1) for _ in range(len(old) + 1)]
|
|
193
|
+
for i in range(len(old) - 1, -1, -1):
|
|
194
|
+
for j in range(len(new) - 1, -1, -1):
|
|
195
|
+
if old[i] == new[j]:
|
|
196
|
+
table[i][j] = table[i + 1][j + 1] + 1
|
|
197
|
+
else:
|
|
198
|
+
table[i][j] = max(table[i + 1][j], table[i][j + 1])
|
|
199
|
+
return table
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _backtrack(
|
|
203
|
+
table: list[list[int]], old: list[str], new: list[str]
|
|
204
|
+
) -> list[_Op]:
|
|
205
|
+
"""Backtracks the LCS table into an ordered edit script."""
|
|
206
|
+
ops: list[_Op] = []
|
|
207
|
+
i = 0
|
|
208
|
+
j = 0
|
|
209
|
+
while i < len(old) and j < len(new):
|
|
210
|
+
if old[i] == new[j]:
|
|
211
|
+
ops.append(_Op(_OpKind.EQUAL, old[i]))
|
|
212
|
+
i += 1
|
|
213
|
+
j += 1
|
|
214
|
+
elif table[i + 1][j] >= table[i][j + 1]:
|
|
215
|
+
ops.append(_Op(_OpKind.DELETE, old[i]))
|
|
216
|
+
i += 1
|
|
217
|
+
else:
|
|
218
|
+
ops.append(_Op(_OpKind.INSERT, new[j]))
|
|
219
|
+
j += 1
|
|
220
|
+
ops.extend(_Op(_OpKind.DELETE, line) for line in old[i:])
|
|
221
|
+
ops.extend(_Op(_OpKind.INSERT, line) for line in new[j:])
|
|
222
|
+
return ops
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _fallback_ops(old: list[str], new: list[str]) -> list[_Op]:
|
|
226
|
+
"""Returns a naive diff for large inputs: delete all, then insert all."""
|
|
227
|
+
ops: list[_Op] = [_Op(_OpKind.DELETE, line) for line in old]
|
|
228
|
+
ops.extend(_Op(_OpKind.INSERT, line) for line in new)
|
|
229
|
+
return ops
|