textual-diff-view 0.1.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.
@@ -0,0 +1 @@
1
+ from ._diff_view import DiffView as DiffView
@@ -0,0 +1,783 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ import asyncio
5
+ import difflib
6
+ from itertools import starmap
7
+ from typing import Iterable, Literal
8
+
9
+ from rich.segment import Segment
10
+ from rich.style import Style as RichStyle
11
+
12
+ from textual.app import ComposeResult
13
+ from textual.content import Content, Span
14
+ from textual.geometry import Size
15
+ from textual import highlight
16
+ from textual import events
17
+
18
+ from textual.css.styles import RulesMap
19
+ from textual.selection import Selection
20
+ from textual.strip import Strip
21
+ from textual.style import Style
22
+ from textual.reactive import reactive, var
23
+ from textual.visual import Visual, RenderOptions
24
+ from textual.widget import Widget
25
+ from textual.widgets import Static
26
+ from textual import containers
27
+
28
+ from textual._loop import loop_last
29
+
30
+ type Annotation = Literal["+", "-", "/", " "]
31
+
32
+
33
+ class Ellipsis(Static):
34
+ """A non selectable Static for the ellipsis."""
35
+ ALLOW_SELECT = False
36
+
37
+
38
+ class DiffScrollContainer(containers.HorizontalGroup):
39
+ scroll_link: var[Widget | None] = var(None)
40
+ DEFAULT_CSS = """
41
+ DiffScrollContainer {
42
+ overflow: scroll hidden;
43
+ scrollbar-size: 0 0;
44
+ height: auto;
45
+ }
46
+ """
47
+
48
+ def watch_scroll_x(self, old_value: float, new_value: float) -> None:
49
+ super().watch_scroll_x(old_value, new_value)
50
+ if self.scroll_link:
51
+ self.scroll_link.scroll_x = new_value
52
+
53
+
54
+ class LineContent(Visual):
55
+ def __init__(
56
+ self,
57
+ code_lines: list[Content | None],
58
+ line_styles: list[str],
59
+ width: int | None = None,
60
+ ) -> None:
61
+ self.code_lines = code_lines
62
+ self.line_styles = line_styles
63
+ self._width = width
64
+
65
+ def render_strips(
66
+ self, width: int, height: int | None, style: Style, options: RenderOptions
67
+ ) -> list[Strip]:
68
+ strips: list[Strip] = []
69
+ y = 0
70
+ selection = options.selection
71
+ selection_style = options.selection_style or Style.null()
72
+ for y, (line, color) in enumerate(zip(self.code_lines, self.line_styles)):
73
+ if line is None:
74
+ line = Content.styled("╲" * width, "$foreground 15%")
75
+ else:
76
+ if selection is not None:
77
+ if span := selection.get_span(y):
78
+ start, end = span
79
+ if end == -1:
80
+ end = len(line)
81
+ line = line.stylize(selection_style, start, end)
82
+ if line.cell_length < width:
83
+ line = line.pad_right(width - line.cell_length)
84
+
85
+ line = line.stylize_before(color).stylize_before(style)
86
+ x = 0
87
+ meta = {"offset": (x, y)}
88
+ segments = []
89
+ for text, rich_style, _ in line.render_segments():
90
+ if rich_style is not None:
91
+ meta["offset"] = (x, y)
92
+ segments.append(
93
+ Segment(text, rich_style + RichStyle.from_meta(meta))
94
+ )
95
+ else:
96
+ segments.append(Segment(text, rich_style))
97
+ x += len(text)
98
+
99
+ strips.append(Strip(segments, line.cell_length))
100
+ return strips
101
+
102
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
103
+ if self._width is not None:
104
+ return self._width
105
+ return max(line.cell_length for line in self.code_lines if line is not None)
106
+
107
+ def get_minimal_width(self, rules: RulesMap) -> int:
108
+ return 1
109
+
110
+ def get_height(self, rules: RulesMap, width: int) -> int:
111
+ return len(self.line_styles)
112
+
113
+
114
+ class LineAnnotations(Widget):
115
+ """A vertical strip next to the code, containing line numbers or symbols."""
116
+
117
+ DEFAULT_CSS = """
118
+ LineAnnotations {
119
+ width: auto;
120
+ height: auto;
121
+ }
122
+ """
123
+ numbers: reactive[list[Content]] = reactive(list)
124
+
125
+ def __init__(
126
+ self,
127
+ numbers: Iterable[Content],
128
+ *,
129
+ name: str | None = None,
130
+ id: str | None = None,
131
+ classes: str | None = None,
132
+ disabled: bool = False,
133
+ ):
134
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
135
+ self.numbers = list(numbers)
136
+
137
+ @property
138
+ def total_width(self) -> int:
139
+ return self.number_width
140
+
141
+ def get_content_width(self, container: Size, viewport: Size) -> int:
142
+ return self.total_width
143
+
144
+ def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
145
+ return len(self.numbers)
146
+
147
+ @property
148
+ def number_width(self) -> int:
149
+ return max(number.cell_length for number in self.numbers) if self.numbers else 0
150
+
151
+ def render_line(self, y: int) -> Strip:
152
+ width = self.total_width
153
+ visual_style = self.visual_style
154
+ rich_style = visual_style.rich_style
155
+ try:
156
+ number = self.numbers[y]
157
+ except IndexError:
158
+ number = Content.empty()
159
+
160
+ strip = Strip(
161
+ number.render_segments(visual_style), cell_length=number.cell_length
162
+ )
163
+ strip = strip.adjust_cell_length(width, rich_style)
164
+ return strip
165
+
166
+
167
+ class DiffCode(Static):
168
+ """Container for the code."""
169
+
170
+ DEFAULT_CSS = """
171
+ DiffCode {
172
+ width: auto;
173
+ height: auto;
174
+ min-width: 1fr;
175
+ }
176
+ """
177
+ ALLOW_SELECT = True
178
+
179
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
180
+ visual = self._render()
181
+ if isinstance(visual, LineContent):
182
+ text = "\n".join(
183
+ "" if line is None else line.plain for line in visual.code_lines
184
+ )
185
+ else:
186
+ return None
187
+ return selection.extract(text), "\n"
188
+
189
+
190
+ def fill_lists[T](list_a: list[T], list_b: list[T], fill_value: T) -> None:
191
+ """Make two lists the same size by extending the smaller with a fill value.
192
+
193
+ Args:
194
+ list_a: The first list.
195
+ list_b: The second list.
196
+ fill_value: Value used to extend a list.
197
+
198
+ """
199
+ a_length = len(list_a)
200
+ b_length = len(list_b)
201
+ if a_length != b_length:
202
+ if a_length > b_length:
203
+ list_b.extend([fill_value] * (a_length - b_length))
204
+ elif b_length > a_length:
205
+ list_a.extend([fill_value] * (b_length - a_length))
206
+
207
+
208
+ class DiffView(containers.VerticalGroup):
209
+ """A formatted diff in unified or split format."""
210
+
211
+ code_before: reactive[str] = reactive("")
212
+ """The before code."""
213
+ code_after: reactive[str] = reactive("")
214
+ """The after code."""
215
+ path1: reactive[str] = reactive("")
216
+ """Path for the before code."""
217
+ path2: reactive[str] = reactive("")
218
+ """Path for the after code."""
219
+ split: reactive[bool] = reactive(True, recompose=True)
220
+ """Enable split view?"""
221
+ annotations: var[bool] = var(False, toggle_class="-with-annotations")
222
+ """Show annotations?"""
223
+ auto_split: var[bool] = var(False)
224
+ """Automaticallly enable split view if there is enough space?"""
225
+
226
+ DEFAULT_CSS = """
227
+ DiffView {
228
+ width: 1fr;
229
+ height: auto;
230
+ .diff-group {
231
+ height: auto;
232
+ background: $foreground 4%;
233
+ }
234
+ .annotations { width: 1; }
235
+ &.-with-annotations {
236
+ .annotations { width: auto; }
237
+ }
238
+ .title {
239
+ border-bottom: dashed $foreground 20%;
240
+ }
241
+ Ellipsis {
242
+ text-align: center;
243
+ width: 1fr;
244
+ color: $text-primary;
245
+ text-style:bold;
246
+ offset-x: -1;
247
+ }
248
+ }
249
+ """
250
+
251
+ NUMBER_STYLES = {
252
+ "+": "$text-success 80% on $success 20%",
253
+ "-": "$text-error 80% on $error 20%",
254
+ " ": "$foreground 30% on $foreground 3%",
255
+ }
256
+ """Line number styles."""
257
+ ANNOTATION_STYLES = {
258
+ "+": "bold $text-success",
259
+ "-": "bold $text-error",
260
+ " ": ""
261
+ }
262
+ """Annotation styles (+ or -)."""
263
+ LINE_STYLES = {
264
+ "+": "on $success 10%",
265
+ "-": "on $error 10%",
266
+ " ": "",
267
+ "/": "",
268
+ }
269
+ """Base style for lines."""
270
+ EDGE_STYLES = {
271
+ "+": "$text-success 30% on $success 20%",
272
+ "-": "$text-error 30% on $error 20%",
273
+ " ": "$foreground 10% on $foreground 3%",
274
+ }
275
+ """Style for edge of numbers,"""
276
+
277
+ def __init__(
278
+ self,
279
+ path1: str,
280
+ path2: str,
281
+ code_before: str,
282
+ code_after: str,
283
+ *,
284
+ name: str | None = None,
285
+ id: str | None = None,
286
+ classes: str | None = None,
287
+ disabled: bool = False,
288
+ ):
289
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
290
+ self.set_reactive(DiffView.path1, path1)
291
+ self.set_reactive(DiffView.path2, path2)
292
+ self.set_reactive(DiffView.code_before, code_before.expandtabs())
293
+ self.set_reactive(DiffView.code_after, code_after.expandtabs())
294
+ self._grouped_opcodes: list[list[tuple[str, int, int, int, int]]] | None = None
295
+ self._highlighted_code_lines: tuple[list[Content], list[Content]] | None = None
296
+
297
+ async def prepare(self) -> None:
298
+ """Do CPU work in a thread.
299
+
300
+ Call this method prior to composing or mounting to ensure lazy calculated
301
+ data structures run in a thread. Otherwise the work will be done in the async
302
+ loop, potentially causing a brief freeze.
303
+
304
+ """
305
+
306
+ def prepare() -> None:
307
+ """Call properties which will lazily update data structures."""
308
+ self.grouped_opcodes
309
+ self.highlighted_code_lines
310
+
311
+ await asyncio.to_thread(prepare)
312
+
313
+ @property
314
+ def grouped_opcodes(self) -> list[list[tuple[str, int, int, int, int]]]:
315
+ if self._grouped_opcodes is None:
316
+ text_lines_a = self.code_before.splitlines()
317
+ text_lines_b = self.code_after.splitlines()
318
+ sequence_matcher = difflib.SequenceMatcher(
319
+ lambda character: character in {" ", "\t"},
320
+ text_lines_a,
321
+ text_lines_b,
322
+ autojunk=True,
323
+ )
324
+ self._grouped_opcodes = list(sequence_matcher.get_grouped_opcodes())
325
+
326
+ return self._grouped_opcodes
327
+
328
+ @property
329
+ def counts(self) -> tuple[int, int]:
330
+ """Additions and removals."""
331
+ additions = 0
332
+ removals = 0
333
+ for group in self.grouped_opcodes:
334
+ for tag, i1, i2, j1, j2 in group:
335
+ if tag == "delete":
336
+ removals += i2 - i1
337
+ elif tag == "replace":
338
+ additions += j2 - j1
339
+ removals += i2 - i1
340
+ elif tag == "insert":
341
+ additions += j2 - j1
342
+ return additions, removals
343
+
344
+ @classmethod
345
+ def _highlight_diff_lines(
346
+ cls, lines_a: list[Content], lines_b: list[Content]
347
+ ) -> tuple[list[Content], list[Content]]:
348
+ """Diff two groups of lines.
349
+
350
+ Args:
351
+ lines_a: Lines before.
352
+ lines_b: Lines after
353
+
354
+ Returns:
355
+ A pair of highlighted lists of lines.
356
+ """
357
+ code_a = Content("\n").join(content for content in lines_a)
358
+ code_b = Content("\n").join(content for content in lines_b)
359
+ sequence_matcher = difflib.SequenceMatcher(
360
+ lambda character: character in {" ", "\t"},
361
+ code_a.plain,
362
+ code_b.plain,
363
+ autojunk=True,
364
+ )
365
+ spans_a: list[Span] = []
366
+ spans_b: list[Span] = []
367
+ for tag, i1, i2, j1, j2 in sequence_matcher.get_opcodes():
368
+ if tag in {"delete", "replace"}:
369
+ spans_a.append(Span(i1, i2, "on $error 30%"))
370
+ if tag in {"insert", "replace"}:
371
+ spans_b.append(Span(j1, j2, "on $success 30%"))
372
+ diffed_lines_a = code_a.add_spans(spans_a).split("\n")
373
+ diffed_lines_b = code_b.add_spans(spans_b).split("\n")
374
+ return diffed_lines_a, diffed_lines_b
375
+
376
+ @property
377
+ def highlighted_code_lines(self) -> tuple[list[Content], list[Content]]:
378
+ """Get syntax highlighted code for both files, as a list of lines.
379
+
380
+ Returns:
381
+ A pair of line lists for `code_before` and `code_after`
382
+ """
383
+
384
+ if self._highlighted_code_lines is None:
385
+ language1 = highlight.guess_language(self.code_before, self.path1)
386
+ language2 = highlight.guess_language(self.code_after, self.path2)
387
+ text_lines_a = self.code_before.splitlines()
388
+ text_lines_b = self.code_after.splitlines()
389
+
390
+ code_a = highlight.highlight(
391
+ "\n".join(text_lines_a), language=language1, path=self.path1
392
+ )
393
+ code_b = highlight.highlight(
394
+ "\n".join(text_lines_b), language=language2, path=self.path2
395
+ )
396
+
397
+ lines_a = code_a.split("\n")
398
+ lines_b = code_b.split("\n")
399
+
400
+ if self.code_before:
401
+ for group in self.grouped_opcodes:
402
+ for tag, i1, i2, j1, j2 in group:
403
+ # Show character level diff only when there is the same number of lines
404
+ # Otherwise you get noisy diffs that don't make a great deal of sense
405
+ if tag == "replace" and (j2 - j1) == (i2 - i1):
406
+ diff_lines_a, diff_lines_b = self._highlight_diff_lines(
407
+ lines_a[i1:i2], lines_b[j1:j2]
408
+ )
409
+ lines_a[i1:i2] = diff_lines_a
410
+ lines_b[j1:j2] = diff_lines_b
411
+
412
+ self._highlighted_code_lines = (lines_a, lines_b)
413
+
414
+ return self._highlighted_code_lines
415
+
416
+ def get_title(self) -> Content:
417
+ """Get a title for the diff view.
418
+
419
+ Returns:
420
+ A Content instance.
421
+ """
422
+ additions, removals = self.counts
423
+ title = Content.from_markup(
424
+ "📄 [dim]$path[/dim] ([$text-success][b]+$additions[/b][/], [$text-error][b]-$removals[/b][/])",
425
+ path=self.path2,
426
+ additions=additions,
427
+ removals=removals,
428
+ additions_label="addition" if additions == 1 else "additions",
429
+ removals_label="removals" if removals == 1 else "removals",
430
+ ).stylize_before("$text")
431
+ return title
432
+
433
+ def compose(self) -> ComposeResult:
434
+ """Compose either split or unified view."""
435
+
436
+ yield Static(self.get_title(), classes="title")
437
+ if self.split:
438
+ yield from self.compose_split()
439
+ else:
440
+ yield from self.compose_unified()
441
+
442
+ def _check_auto_split(self, width: int):
443
+ if self.auto_split:
444
+ lines_a, lines_b = self.highlighted_code_lines
445
+ split_width = max([line.cell_length for line in (lines_a + lines_b)]) * 2
446
+ split_width += 4 + 2 * (
447
+ max(
448
+ [
449
+ len(str(len(lines_a))),
450
+ len(str(len(lines_b))),
451
+ ]
452
+ )
453
+ )
454
+ split_width += 3 * 2 if self.annotations else 2
455
+ self.split = width >= split_width
456
+
457
+ async def on_resize(self, event: events.Resize) -> None:
458
+ self._check_auto_split(event.size.width)
459
+
460
+ async def on_mount(self) -> None:
461
+ self._check_auto_split(self.size.width)
462
+
463
+ def compose_unified(self) -> ComposeResult:
464
+ lines_a, lines_b = self.highlighted_code_lines
465
+
466
+ for last, group in loop_last(self.grouped_opcodes):
467
+ line_numbers_a: list[int | None] = []
468
+ line_numbers_b: list[int | None] = []
469
+ annotations: list[str] = []
470
+ code_lines: list[Content | None] = []
471
+ for tag, i1, i2, j1, j2 in group:
472
+ if tag == "equal":
473
+ for line_offset, line in enumerate(lines_a[i1:i2], 1):
474
+ annotations.append(" ")
475
+ line_numbers_a.append(i1 + line_offset)
476
+ line_numbers_b.append(j1 + line_offset)
477
+ code_lines.append(line)
478
+ continue
479
+ if tag in {"delete", "replace"}:
480
+ for line_offset, line in enumerate(lines_a[i1:i2], 1):
481
+ annotations.append("-")
482
+ line_numbers_a.append(i1 + line_offset)
483
+ line_numbers_b.append(None)
484
+ code_lines.append(line)
485
+ if tag in {"insert", "replace"}:
486
+ for line_offset, line in enumerate(lines_b[j1:j2], 1):
487
+ annotations.append("+")
488
+ line_numbers_a.append(None)
489
+ line_numbers_b.append(j1 + line_offset)
490
+ code_lines.append(line)
491
+
492
+ NUMBER_STYLES = self.NUMBER_STYLES
493
+ LINE_STYLES = self.LINE_STYLES
494
+ EDGE_STYLES = self.EDGE_STYLES
495
+ ANNOTATION_STYLES = self.ANNOTATION_STYLES
496
+
497
+ line_number_width = max(
498
+ len("" if line_no is None else str(line_no))
499
+ for line_no in (line_numbers_a + line_numbers_b)
500
+ )
501
+
502
+ with containers.HorizontalGroup(classes="diff-group"):
503
+ yield LineAnnotations(
504
+ [
505
+ (
506
+ Content(f"▎{' ' * line_number_width} ")
507
+ if line_no is None
508
+ else Content(f"▎{line_no:>{line_number_width}} ")
509
+ )
510
+ .stylize(NUMBER_STYLES[annotation], 1)
511
+ .stylize(EDGE_STYLES[annotation], 0, 1)
512
+ for line_no, annotation in zip(line_numbers_a, annotations)
513
+ ]
514
+ )
515
+
516
+ yield LineAnnotations(
517
+ [
518
+ (
519
+ Content(f" {' ' * line_number_width} ")
520
+ if line_no is None
521
+ else Content(f" {line_no:>{line_number_width}} ")
522
+ ).stylize(NUMBER_STYLES[annotation])
523
+ for line_no, annotation in zip(line_numbers_b, annotations)
524
+ ]
525
+ )
526
+
527
+ yield LineAnnotations(
528
+ [
529
+ (Content(f" {annotation} "))
530
+ .stylize(LINE_STYLES[annotation])
531
+ .stylize(ANNOTATION_STYLES[annotation])
532
+ for annotation in annotations
533
+ ],
534
+ classes="annotations",
535
+ )
536
+ code_line_styles = [
537
+ LINE_STYLES[annotation] for annotation in annotations
538
+ ]
539
+ with DiffScrollContainer():
540
+ yield DiffCode(LineContent(code_lines, code_line_styles))
541
+
542
+ if not last:
543
+ yield Ellipsis("⋮")
544
+
545
+ def compose_split(self) -> ComposeResult:
546
+ lines_a, lines_b = self.highlighted_code_lines
547
+
548
+ annotation_hatch = Content.styled("╲" * 3, "$foreground 15%")
549
+ annotation_blank = Content(" " * 3)
550
+
551
+ def make_annotation(
552
+ annotation: Annotation, highlight_annotation: Literal["+", "-"]
553
+ ) -> Content:
554
+ """Format an annotation.
555
+
556
+ Args:
557
+ annotation: Annotation to format.
558
+ highlight_annotation: Annotation to highlight ('+' or '-')
559
+
560
+ Returns:
561
+ Content with annotation.
562
+ """
563
+ if annotation == highlight_annotation:
564
+ return (
565
+ Content(f" {annotation} ")
566
+ .stylize(self.LINE_STYLES[annotation])
567
+ .stylize(self.ANNOTATION_STYLES.get(annotation, ""))
568
+ )
569
+ if annotation == "/":
570
+ return annotation_hatch
571
+ return annotation_blank
572
+
573
+ for last, group in loop_last(self.grouped_opcodes):
574
+ line_numbers_a: list[int | None] = []
575
+ line_numbers_b: list[int | None] = []
576
+ annotations_a: list[Annotation] = []
577
+ annotations_b: list[Annotation] = []
578
+ code_lines_a: list[Content | None] = []
579
+ code_lines_b: list[Content | None] = []
580
+ for tag, i1, i2, j1, j2 in group:
581
+ if tag == "equal":
582
+ for line_offset, line in enumerate(lines_a[i1:i2], 1):
583
+ annotations_a.append(" ")
584
+ annotations_b.append(" ")
585
+ line_numbers_a.append(i1 + line_offset)
586
+ line_numbers_b.append(j1 + line_offset)
587
+ code_lines_a.append(line)
588
+ code_lines_b.append(line)
589
+ else:
590
+ if tag in {"delete", "replace"}:
591
+ for line_number, line in enumerate(lines_a[i1:i2], i1 + 1):
592
+ annotations_a.append("-")
593
+ line_numbers_a.append(line_number)
594
+ code_lines_a.append(line)
595
+ if tag in {"insert", "replace"}:
596
+ for line_number, line in enumerate(lines_b[j1:j2], j1 + 1):
597
+ annotations_b.append("+")
598
+ line_numbers_b.append(line_number)
599
+ code_lines_b.append(line)
600
+ fill_lists(code_lines_a, code_lines_b, None)
601
+ fill_lists(annotations_a, annotations_b, "/")
602
+ fill_lists(line_numbers_a, line_numbers_b, None)
603
+
604
+ if line_numbers_a or line_numbers_b:
605
+ line_number_width = max(
606
+ 0 if line_no is None else len(str(line_no))
607
+ for line_no in (line_numbers_a + line_numbers_b)
608
+ )
609
+ else:
610
+ line_number_width = 1
611
+
612
+ hatch = Content.styled("╲" * (2 + line_number_width), "$foreground 15%")
613
+
614
+ def format_number(line_no: int | None, annotation: str) -> Content:
615
+ """Format a line number with an annotation.
616
+
617
+ Args:
618
+ line_no: Line number or `None` if there is no line here.
619
+ annotation: An annotation string ('+', '-', or ' ')
620
+
621
+ Returns:
622
+ Content for use in the `LineAnnotations` widget.
623
+ """
624
+ return (
625
+ hatch
626
+ if line_no is None
627
+ else Content(f"▎{line_no:>{line_number_width}} ")
628
+ .stylize(self.NUMBER_STYLES[annotation], 1)
629
+ .stylize(self.EDGE_STYLES[annotation], 0, 1)
630
+ )
631
+
632
+ with containers.HorizontalGroup(classes="diff-group"):
633
+ # Before line numbers
634
+ yield LineAnnotations(
635
+ starmap(format_number, zip(line_numbers_a, annotations_a))
636
+ )
637
+ # Before annotations
638
+ yield LineAnnotations(
639
+ [make_annotation(annotation, "-") for annotation in annotations_a],
640
+ classes="annotations",
641
+ )
642
+
643
+ code_line_styles = [
644
+ self.LINE_STYLES[annotation] for annotation in annotations_a
645
+ ]
646
+ line_width = max(
647
+ line.cell_length
648
+ for line in code_lines_a + code_lines_b
649
+ if line is not None
650
+ )
651
+ # Before code
652
+ with DiffScrollContainer() as scroll_container_a:
653
+ yield DiffCode(
654
+ LineContent(code_lines_a, code_line_styles, width=line_width)
655
+ )
656
+
657
+ # After line numbers
658
+ yield LineAnnotations(
659
+ starmap(format_number, zip(line_numbers_b, annotations_b))
660
+ )
661
+ # After annotations
662
+ yield LineAnnotations(
663
+ [make_annotation(annotation, "+") for annotation in annotations_b],
664
+ classes="annotations",
665
+ )
666
+
667
+ code_line_styles = [
668
+ self.LINE_STYLES[annotation] for annotation in annotations_b
669
+ ]
670
+ # After code
671
+ with DiffScrollContainer() as scroll_container_b:
672
+ yield DiffCode(
673
+ LineContent(code_lines_b, code_line_styles, width=line_width)
674
+ )
675
+
676
+ # Link scroll containers, so they scroll together
677
+ scroll_container_a.scroll_link = scroll_container_b
678
+ scroll_container_b.scroll_link = scroll_container_a
679
+
680
+ if not last:
681
+ with containers.HorizontalGroup():
682
+ yield Ellipsis("⋮")
683
+ yield Ellipsis("⋮")
684
+
685
+
686
+ if __name__ == "__main__":
687
+ SOURCE1 = '''\
688
+ def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
689
+ \t"""Iterate and generate a tuple with a flag for first value."""
690
+ \titer_values = iter(values)
691
+ try:
692
+ value = next(iter_values)
693
+ except StopIteration:
694
+ return
695
+ yield True, value
696
+ for value in iter_values:
697
+ yield False, value
698
+
699
+
700
+ def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
701
+ """Iterate and generate a tuple with a flag for first and last value."""
702
+ iter_values = iter(values)
703
+ try:
704
+ previous_value = next(iter_values)
705
+ except StopIteration:
706
+ return
707
+ first = True
708
+ for value in iter_values:
709
+ yield first, False, previous_value
710
+ first = False
711
+ previous_value = value
712
+ yield first, True, previous_value
713
+
714
+ '''
715
+
716
+ SOURCE2 = '''\
717
+ def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
718
+ """Iterate and generate a tuple with a flag for first value.
719
+
720
+ Args:
721
+ values: iterables of values.
722
+
723
+ Returns:
724
+ Iterable of a boolean to indicate first value, and a value from the iterable.
725
+ """
726
+ iter_values = iter(values)
727
+ try:
728
+ value = next(iter_values)
729
+ except StopIteration:
730
+ return
731
+ yield True, value
732
+ for value in iter_values:
733
+ yield False, value
734
+
735
+
736
+ def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
737
+ """Iterate and generate a tuple with a flag for last value."""
738
+ iter_values = iter(values)
739
+ try:
740
+ previous_value = next(iter_values)
741
+ except StopIteration:
742
+ return
743
+ for value in iter_values:
744
+ yield False, previous_value
745
+ previous_value = value
746
+ yield True, previous_value
747
+
748
+
749
+ def loop_first_last(values: Iterable[ValueType]) -> Iterable[tuple[bool, bool, ValueType]]:
750
+ """Iterate and generate a tuple with a flag for first and last value."""
751
+ iter_values = iter(values)
752
+ try:
753
+ previous_value = next(iter_values) # Get previous value
754
+ except StopIteration:
755
+ return
756
+ first = True
757
+
758
+ '''
759
+ from textual.app import App
760
+ from textual.widgets import Footer
761
+ from textual import containers
762
+
763
+ class DiffApp(App):
764
+ BINDINGS = [
765
+ ("space", "split", "Toggle split"),
766
+ ("a", "toggle_annotations", "Toggle annotations"),
767
+ ]
768
+
769
+ def compose(self) -> ComposeResult:
770
+ with containers.VerticalScroll():
771
+ yield DiffView("foo.py", "foo.py", SOURCE1, SOURCE2)
772
+ yield Footer()
773
+
774
+ def action_split(self) -> None:
775
+ self.query_one(DiffView).split = not self.query_one(DiffView).split
776
+
777
+ def action_toggle_annotations(self) -> None:
778
+ self.query_one(DiffView).annotations = not self.query_one(
779
+ DiffView
780
+ ).annotations
781
+
782
+ app = DiffApp()
783
+ app.run()
File without changes
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: textual-diff-view
3
+ Version: 0.1.0
4
+ Summary: Beautiful Diff view widget for Textual applications
5
+ Author: Will McGugan
6
+ Author-email: Will McGugan <willmcgugan@gmail.com>
7
+ Requires-Dist: textual>=8.2.1
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
11
+ # diff-view
12
+ The Diff View used in Toad
@@ -0,0 +1,6 @@
1
+ textual_diff_view/__init__.py,sha256=2HcWn9kAMjFu2oDDU4TvPMJ-sKeNA7KCRIrNB9XMsbQ,44
2
+ textual_diff_view/_diff_view.py,sha256=Iv4Nh7jUQqdgtthdh1oaqlTJIEhSXoOCFOzLw5kI8Cg,27735
3
+ textual_diff_view/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ textual_diff_view-0.1.0.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
5
+ textual_diff_view-0.1.0.dist-info/METADATA,sha256=EvsHb5uhOWb9FyuDvtJ1k9pwhJmzrvoyh0-25hO2m9k,328
6
+ textual_diff_view-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any