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/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