diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7fe15e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Textual Diff View — a terminal UI widget (`DiffView`) for displaying diffs, built on the Textual framework. Published to PyPI as `textual-diff-view`. + +## Commands + +```bash +# Run all tests +make test +# or: uv run pytest tests/ + +# Update visual snapshots +make update-snapshots +# or: uv run pytest tests/ --snapshot-update + +# Run a single test +uv run pytest tests/test_diff_view.py::test_name + +# Run example app +uv run python examples/tdiff.py examples/example1.rs examples/example2.rs +``` + +## Architecture + +The entire widget lives in `src/textual_diff_view/_diff_view.py`. Public API is just `DiffView` and `LoadError`, exported from `__init__.py`. + +**Widget hierarchy** (all Textual widgets): +- `DiffView` (main widget, `VerticalGroup`) — owns the diff state and renders either split or unified layout via `compose_split()` / `compose_unified()`. Uses Textual's `reactive` attributes with `recompose=True` to re-layout on mode change. +- `DiffScrollContainer` (`HorizontalGroup`) — syncs horizontal scroll between left/right panels in split view. +- `LineContent` (`Visual`) — renders a single diff line with syntax highlighting and character-level diff marks. +- `LineAnnotations` — shows line numbers and optional +/- markers. +- `DiffCode` (`Static`) — container for code lines, supports text selection. +- `Ellipsis` — collapsed section indicator (⋮). + +**Diff computation**: Uses `difflib.SequenceMatcher` for line-level diffs, then character-level diffs on changed lines. Expensive computation is offloaded via `prepare()` (runs in thread pool). Results are lazily cached via properties (`grouped_opcodes`, `highlighted_code_lines`). + +**Syntax highlighting**: Uses Textual's `highlight` module, auto-detecting language from file path. + +## Testing + +- `tests/test_diff_view.py` — unit tests (fill_lists, counting, opcodes, tab expansion, loading, mounting) +- `tests/test_snapshots.py` — visual regression tests via `pytest-textual-snapshot` +- CI runs on Linux/Windows/macOS across Python 3.11–3.14 diff --git a/examples/tdiff.py b/examples/tdiff.py index 71bfb6f..93611d3 100644 --- a/examples/tdiff.py +++ b/examples/tdiff.py @@ -12,10 +12,12 @@ class DiffApp(App): BINDINGS = [ ("space", "toggle('split')", "Toggle split"), ("a", "toggle('annotations')", "Toggle annotations"), + ("w", "toggle('wrap')", "Toggle wrap"), ] split = var(True) annotations = var(True) + wrap = var(False) def __init__(self, original: str, modified: str) -> None: self.original = original @@ -32,7 +34,7 @@ async def on_mount(self) -> None: except LoadError as error: self.notify(str(error), title="Failed to load code", severity="error") else: - diff_view.data_bind(DiffApp.split, DiffApp.annotations) + diff_view.data_bind(DiffApp.split, DiffApp.annotations, DiffApp.wrap) await self.query_one("#diff-container").mount(diff_view) diff --git a/src/textual_diff_view/_diff_view.py b/src/textual_diff_view/_diff_view.py index 966933a..7cd8fb6 100644 --- a/src/textual_diff_view/_diff_view.py +++ b/src/textual_diff_view/_diff_view.py @@ -4,6 +4,7 @@ import asyncio import difflib from itertools import starmap +from math import ceil from os import PathLike from pathlib import Path from typing import Iterable, Literal @@ -122,6 +123,144 @@ def get_height(self, rules: RulesMap, width: int) -> int: return len(self.line_styles) +class FoldedLineContent(Visual): + """A visual that folds (wraps) code lines at render time, with inline gutter.""" + + def __init__( + self, + annotations: list[Content], + continuations: list[Content], + code_lines: list[Content | None], + line_styles: list[str], + code_lengths: list[int] | None = None, + ) -> None: + self.annotations = annotations + self.continuations = continuations + self.code_lines = code_lines + self.line_styles = line_styles + self.code_lengths = ( + [0 if line is None else line.cell_length for line in code_lines] + if code_lengths is None + else code_lengths + ) + + @property + def _gutter_width(self) -> int: + if not self.annotations: + return 0 + return self.annotations[0].cell_length + + def _fold_width(self, width: int) -> int: + return max(2, width - self._gutter_width) + + def _line_count(self, code_length: int, fold_width: int) -> int: + if code_length <= fold_width: + return 1 + return ceil(code_length / fold_width) + + def get_height(self, rules: RulesMap, width: int) -> int: + fold_width = self._fold_width(width) + return sum( + self._line_count(cl, fold_width) for cl in self.code_lengths + ) + + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: + return container_width + + def get_minimal_width(self, rules: RulesMap) -> int: + return self._gutter_width + 2 + + def render_strips( + self, width: int, height: int | None, style: Style, options: RenderOptions + ) -> list[Strip]: + strips: list[Strip] = [] + fold_width = self._fold_width(width) + y = 0 + selection = options.selection + selection_style = options.selection_style or Style.null() + visual_code_lines: list[str] = [] + + for annotate, cont, content, color, code_length in zip( + self.annotations, + self.continuations, + self.code_lines, + self.line_styles, + self.code_lengths, + ): + line_count = self._line_count(code_length, fold_width) + + if content is None: + hatch = Content.styled("╲" * width, "$foreground 15%") + hatch = hatch.stylize_before(style) + for _ in range(line_count): + meta: dict[str, object] = {"offset": (0, y)} + segments = [ + Segment(text, rich_style + RichStyle.from_meta(meta) if rich_style else rich_style) + for text, rich_style, _ in hatch.render_segments() + ] + strips.append(Strip(segments, width)) + visual_code_lines.append("") + y += 1 + continue + + if content.cell_length <= fold_width: + folded: list[Content] = [content] + else: + folded = content.fold(fold_width) + + actual_count = len(folded) + while len(folded) < line_count: + folded.append(Content("")) + + for i, part in enumerate(folded): + if i == 0: + ann = annotate + elif i < actual_count: + ann = cont + else: + ann = cont + + visual_code_lines.append(part.plain) + ann_width = ann.cell_length + code_w = width - ann_width + + if selection is not None: + if span := selection.get_span(y): + start, end = span + start = max(0, start - ann_width) + if end == -1: + end = len(part) + else: + end = max(0, end - ann_width) + if end > start: + part = part.stylize(selection_style, start, end) + + if part.cell_length < code_w: + part = part.pad_right(code_w - part.cell_length) + + part = part.stylize_before(color) + combined = Content("").join([ann, part]) + combined = combined.stylize_before(style) + + segments = [] + x = 0 + meta = {"offset": (x, y)} + for text, rich_style, _ in combined.render_segments(): + if rich_style is not None: + meta["offset"] = (x, y) + segments.append( + Segment(text, rich_style + RichStyle.from_meta(meta)) + ) + else: + segments.append(Segment(text, rich_style)) + x += len(text) + strips.append(Strip(segments, combined.cell_length)) + y += 1 + + self._visual_code_lines = visual_code_lines + return strips + + class LineAnnotations(Widget): """A vertical strip next to the code, containing line numbers or symbols.""" @@ -189,7 +328,11 @@ class DiffCode(Static): def get_selection(self, selection: Selection) -> tuple[str, str] | None: visual = self._render() - if isinstance(visual, LineContent): + if isinstance(visual, FoldedLineContent): + text = "\n".join( + getattr(visual, "_visual_code_lines", []) + ) + elif isinstance(visual, LineContent): text = "\n".join( "" if line is None else line.plain for line in visual.code_lines ) @@ -234,6 +377,11 @@ class DiffView(containers.VerticalGroup): """Show annotations?""" auto_split: var[bool] = var(False) """Automaticallly enable split view if there is enough space?""" + wrap: reactive[bool] = reactive(False, recompose=True) + """Wrap long lines instead of horizontal scrolling?""" + + WRAP_SYMBOL = "↪" + """Symbol shown in the annotation column for wrapped continuation lines.""" DEFAULT_CSS = """ DiffView { @@ -266,7 +414,7 @@ class DiffView(containers.VerticalGroup): " ": "$foreground 30% on $foreground 3%", } """Line number styles.""" - ANNOTATION_STYLES = {"+": "bold $text-success", "-": "bold $text-error", " ": ""} + ANNOTATION_STYLES = {"+": "$text-success", "-": "$text-error", " ": ""} """Annotation styles (+ or -).""" LINE_STYLES = { "+": "on $success 10%", @@ -494,7 +642,12 @@ def compose(self) -> ComposeResult: """Compose either split or unified view.""" yield Static(self.get_title(), classes="title") - if self.split: + if self.wrap: + if self.split: + yield from self.compose_split_wrap() + else: + yield from self.compose_unified_wrap() + elif self.split: yield from self.compose_split() else: yield from self.compose_unified() @@ -515,10 +668,14 @@ def _check_auto_split(self, width: int): split_width += 3 * 2 if self.annotations else 2 self.split = width >= split_width - async def on_resize(self, event: events.Resize) -> None: + async def watch_annotations(self) -> None: + if self.wrap: + await self.recompose() + + def on_resize(self, event: events.Resize) -> None: self._check_auto_split(event.size.width) - async def on_mount(self) -> None: + def on_mount(self) -> None: self._check_auto_split(self.size.width) def compose_unified(self) -> ComposeResult: @@ -603,6 +760,228 @@ def compose_unified(self) -> ComposeResult: if not last: yield Ellipsis("⋮") + def compose_unified_wrap(self) -> ComposeResult: + lines_a, lines_b = self.highlighted_code_lines + show_annotations = self.annotations + + NUMBER_STYLES = self.NUMBER_STYLES + LINE_STYLES = self.LINE_STYLES + EDGE_STYLES = self.EDGE_STYLES + ANNOTATION_STYLES = self.ANNOTATION_STYLES + + for last, group in loop_last(self.grouped_opcodes): + line_numbers_a: list[int | None] = [] + line_numbers_b: list[int | None] = [] + ann_types: list[str] = [] + code_lines: list[Content | None] = [] + for tag, i1, i2, j1, j2 in group: + if tag == "equal": + for line_offset, line in enumerate(lines_a[i1:i2], 1): + ann_types.append(" ") + line_numbers_a.append(i1 + line_offset) + line_numbers_b.append(j1 + line_offset) + code_lines.append(line) + continue + if tag in {"delete", "replace"}: + for line_offset, line in enumerate(lines_a[i1:i2], 1): + ann_types.append("-") + line_numbers_a.append(i1 + line_offset) + line_numbers_b.append(None) + code_lines.append(line) + if tag in {"insert", "replace"}: + for line_offset, line in enumerate(lines_b[j1:j2], 1): + ann_types.append("+") + line_numbers_a.append(None) + line_numbers_b.append(j1 + line_offset) + code_lines.append(line) + + numw = max( + len("" if n is None else str(n)) + for n in (line_numbers_a + line_numbers_b) + ) + + gutter_annotations: list[Content] = [] + gutter_continuations: list[Content] = [] + + for num_a, num_b, ann in zip(line_numbers_a, line_numbers_b, ann_types): + ga = ( + Content(f"▎{' ' * numw} ") + if num_a is None + else Content(f"▎{num_a:>{numw}} ") + ).stylize(NUMBER_STYLES[ann], 1).stylize(EDGE_STYLES[ann], 0, 1) + + gb = ( + Content(f" {' ' * numw} ") + if num_b is None + else Content(f" {num_b:>{numw}} ") + ).stylize(NUMBER_STYLES[ann]) + + if show_annotations: + ac = (Content(f" {ann} ") + .stylize(LINE_STYLES[ann]) + .stylize(ANNOTATION_STYLES[ann])) + else: + ac = Content(" ").stylize(LINE_STYLES[ann]) + gutter_annotations.append(Content("").join([ga, gb, ac])) + + ga_c = (Content(f"▎{' ' * numw} ") + .stylize(NUMBER_STYLES[ann], 1) + .stylize(EDGE_STYLES[ann], 0, 1)) + gb_c = Content(f" {' ' * numw} ").stylize(NUMBER_STYLES[ann]) + if show_annotations: + ac_c = (Content(f" {self.WRAP_SYMBOL} ") + .stylize(LINE_STYLES[ann]) + .stylize(ANNOTATION_STYLES[ann])) + else: + ac_c = Content(" ").stylize(LINE_STYLES[ann]) + gutter_continuations.append(Content("").join([ga_c, gb_c, ac_c])) + + line_styles = [LINE_STYLES[ann] for ann in ann_types] + + with containers.HorizontalGroup(classes="diff-group"): + yield DiffCode(FoldedLineContent( + gutter_annotations, gutter_continuations, + code_lines, line_styles, + )) + + if not last: + yield Ellipsis("⋮") + + def compose_split_wrap(self) -> ComposeResult: + lines_a, lines_b = self.highlighted_code_lines + show_annotations = self.annotations + + NUMBER_STYLES = self.NUMBER_STYLES + LINE_STYLES = self.LINE_STYLES + EDGE_STYLES = self.EDGE_STYLES + ANNOTATION_STYLES = self.ANNOTATION_STYLES + + for last, group in loop_last(self.grouped_opcodes): + line_numbers_a: list[int | None] = [] + line_numbers_b: list[int | None] = [] + annotations_a: list[Annotation] = [] + annotations_b: list[Annotation] = [] + code_lines_a: list[Content | None] = [] + code_lines_b: list[Content | None] = [] + for tag, i1, i2, j1, j2 in group: + if tag == "equal": + for line_offset, line in enumerate(lines_a[i1:i2], 1): + annotations_a.append(" ") + annotations_b.append(" ") + line_numbers_a.append(i1 + line_offset) + line_numbers_b.append(j1 + line_offset) + code_lines_a.append(line) + code_lines_b.append(line) + else: + if tag in {"delete", "replace"}: + for line_number, line in enumerate(lines_a[i1:i2], i1 + 1): + annotations_a.append("-") + line_numbers_a.append(line_number) + code_lines_a.append(line) + if tag in {"insert", "replace"}: + for line_number, line in enumerate(lines_b[j1:j2], j1 + 1): + annotations_b.append("+") + line_numbers_b.append(line_number) + code_lines_b.append(line) + fill_lists(code_lines_a, code_lines_b, None) + fill_lists(annotations_a, annotations_b, "/") + fill_lists(line_numbers_a, line_numbers_b, None) + + if line_numbers_a or line_numbers_b: + numw = max( + 0 if n is None else len(str(n)) + for n in (line_numbers_a + line_numbers_b) + ) + else: + numw = 1 + + ann_col_width = 3 if show_annotations else 1 + full_gutter_width = 2 + numw + ann_col_width + + def build_side( + line_numbers: list[int | None], + ann_list: list[Annotation], + highlight_ann: Literal["+", "-"], + ) -> tuple[list[Content], list[Content]]: + gutter_anns: list[Content] = [] + gutter_conts: list[Content] = [] + for line_no, ann in zip(line_numbers, ann_list): + if ann == "/": + hatch = Content.styled( + "╲" * full_gutter_width, "$foreground 15%" + ) + gutter_anns.append(hatch) + gutter_conts.append(hatch) + continue + + if line_no is None: + g = (Content(f"▎{' ' * numw} ") + .stylize(NUMBER_STYLES[ann], 1) + .stylize(EDGE_STYLES[ann], 0, 1)) + else: + g = (Content(f"▎{line_no:>{numw}} ") + .stylize(NUMBER_STYLES[ann], 1) + .stylize(EDGE_STYLES[ann], 0, 1)) + + if show_annotations and ann == highlight_ann: + ac = (Content(f" {ann} ") + .stylize(LINE_STYLES[ann]) + .stylize(ANNOTATION_STYLES.get(ann, ""))) + elif show_annotations: + ac = Content(" ") + else: + ac = Content(" ").stylize(LINE_STYLES.get(ann, "")) + gutter_anns.append(Content("").join([g, ac])) + + g_c = (Content(f"▎{' ' * numw} ") + .stylize(NUMBER_STYLES[ann], 1) + .stylize(EDGE_STYLES[ann], 0, 1)) + if show_annotations: + ac_c = (Content(f" {self.WRAP_SYMBOL} ") + .stylize(LINE_STYLES.get(ann, "")) + .stylize(ANNOTATION_STYLES.get(ann, ""))) + else: + ac_c = Content(" ").stylize(LINE_STYLES.get(ann, "")) + gutter_conts.append(Content("").join([g_c, ac_c])) + + return gutter_anns, gutter_conts + + anns_a, conts_a = build_side( + line_numbers_a, annotations_a, "-" + ) + anns_b, conts_b = build_side( + line_numbers_b, annotations_b, "+" + ) + + styles_a = [LINE_STYLES[ann] for ann in annotations_a] + styles_b = [LINE_STYLES[ann] for ann in annotations_b] + + shared_lengths = [ + max( + 0 if a is None else a.cell_length, + 0 if b is None else b.cell_length, + ) + for a, b in zip(code_lines_a, code_lines_b) + ] + + with containers.HorizontalGroup(classes="diff-group"): + diff_code_a = DiffCode(FoldedLineContent( + anns_a, conts_a, code_lines_a, styles_a, shared_lengths, + )) + diff_code_a.styles.width = "1fr" + yield diff_code_a + + diff_code_b = DiffCode(FoldedLineContent( + anns_b, conts_b, code_lines_b, styles_b, shared_lengths, + )) + diff_code_b.styles.width = "1fr" + yield diff_code_b + + if not last: + with containers.HorizontalGroup(): + yield Ellipsis("⋮") + yield Ellipsis("⋮") + def compose_split(self) -> ComposeResult: lines_a, lines_b = self.highlighted_code_lines diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_annotations.svg b/tests/__snapshots__/test_snapshots/test_diff_view_annotations.svg index 01e8441..ccbb11a 100644 --- a/tests/__snapshots__/test_snapshots/test_diff_view_annotations.svg +++ b/tests/__snapshots__/test_snapshots/test_diff_view_annotations.svg @@ -43,21 +43,22 @@ .terminal-r9 { fill: #f8f8f2 } .terminal-r10 { fill: #8a5258 } .terminal-r11 { fill: #dd7d7e } -.terminal-r12 { fill: #ffa6d9 } -.terminal-r13 { fill: #ffd09d;text-decoration: underline; } -.terminal-r14 { fill: #4f8a65 } -.terminal-r15 { fill: #7ada94 } -.terminal-r16 { fill: #d3b7fb } -.terminal-r17 { fill: #83e79c } -.terminal-r18 { fill: #79d693;font-style: italic; } -.terminal-r19 { fill: #4e4f58 } -.terminal-r20 { fill: #8bfba7 } -.terminal-r21 { fill: #82e89d } -.terminal-r22 { fill: #acadb1 } -.terminal-r23 { fill: #d3b7fb;font-weight: bold } -.terminal-r24 { fill: #ffd09d } -.terminal-r25 { fill: #89e89d } -.terminal-r26 { fill: #83ee9e } +.terminal-r12 { fill: #ff8e8e } +.terminal-r13 { fill: #ffa6d9 } +.terminal-r14 { fill: #ffd09d;text-decoration: underline; } +.terminal-r15 { fill: #4f8a65 } +.terminal-r16 { fill: #7ada94 } +.terminal-r17 { fill: #8bfba7 } +.terminal-r18 { fill: #d3b7fb } +.terminal-r19 { fill: #83e79c } +.terminal-r20 { fill: #79d693;font-style: italic; } +.terminal-r21 { fill: #4e4f58 } +.terminal-r22 { fill: #82e89d } +.terminal-r23 { fill: #acadb1 } +.terminal-r24 { fill: #d3b7fb;font-weight: bold } +.terminal-r25 { fill: #ffd09d } +.terminal-r26 { fill: #89e89d } +.terminal-r27 { fill: #83ee9e } @@ -148,19 +149,19 @@ 📄 hello2.py (+5-4) ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ - - defgreet(): + defgreet(name:str): - - print"Hello!" + """Greet anyone""" -╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲ + print(f"Hello, {name}!") + - defgreet(): + defgreet(name:str): + - print"Hello!" + """Greet anyone""" +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲ + print(f"Hello, {name}!") - - greet() + greet('Will') + - greet() + greet('Will') -# Heard joke once: Man goes to doc# Heard joke once: Man goes to doc -# Says he's depressed.# Says he's depressed. - -12 # Man bursts into tears. Says: 'B13 # Man bursts into tears. Says: 'B +# Heard joke once: Man goes to doc# Heard joke once: Man goes to doc +# Says he's depressed.# Says he's depressed. + +12 # Man bursts into tears. Says: 'B13 # Man bursts into tears. Says: 'B 13 14  -14 forninrange(10):15 forninrange(10): -15  - print("Foo")16  + print("Bar") +14 forninrange(10):15 forninrange(10): +15  - print("Foo")16  + print("Bar") diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_defaults.svg b/tests/__snapshots__/test_snapshots/test_diff_view_defaults.svg index 3879a0c..8d53020 100644 --- a/tests/__snapshots__/test_snapshots/test_diff_view_defaults.svg +++ b/tests/__snapshots__/test_snapshots/test_diff_view_defaults.svg @@ -43,21 +43,22 @@ .terminal-r9 { fill: #f8f8f2 } .terminal-r10 { fill: #8a5258 } .terminal-r11 { fill: #dd7d7e } -.terminal-r12 { fill: #ffa6d9 } -.terminal-r13 { fill: #ffd09d;text-decoration: underline; } -.terminal-r14 { fill: #4f8a65 } -.terminal-r15 { fill: #7ada94 } -.terminal-r16 { fill: #d3b7fb } -.terminal-r17 { fill: #83e79c } -.terminal-r18 { fill: #79d693;font-style: italic; } -.terminal-r19 { fill: #4e4f58 } -.terminal-r20 { fill: #8bfba7 } -.terminal-r21 { fill: #82e89d } -.terminal-r22 { fill: #acadb1 } -.terminal-r23 { fill: #d3b7fb;font-weight: bold } -.terminal-r24 { fill: #ffd09d } -.terminal-r25 { fill: #89e89d } -.terminal-r26 { fill: #83ee9e } +.terminal-r12 { fill: #ff8e8e } +.terminal-r13 { fill: #ffa6d9 } +.terminal-r14 { fill: #ffd09d;text-decoration: underline; } +.terminal-r15 { fill: #4f8a65 } +.terminal-r16 { fill: #7ada94 } +.terminal-r17 { fill: #8bfba7 } +.terminal-r18 { fill: #d3b7fb } +.terminal-r19 { fill: #83e79c } +.terminal-r20 { fill: #79d693;font-style: italic; } +.terminal-r21 { fill: #4e4f58 } +.terminal-r22 { fill: #82e89d } +.terminal-r23 { fill: #acadb1 } +.terminal-r24 { fill: #d3b7fb;font-weight: bold } +.terminal-r25 { fill: #ffd09d } +.terminal-r26 { fill: #89e89d } +.terminal-r27 { fill: #83ee9e } @@ -148,19 +149,19 @@ 📄 hello2.py (+5-4) ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ -defgreet():defgreet(name:str): -print"Hello!""""Greet anyone""" -╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲print(f"Hello, {name}!") +defgreet():defgreet(name:str): +print"Hello!""""Greet anyone""" +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲print(f"Hello, {name}!") -greet()greet('Will') +greet()greet('Will') -# Heard joke once: Man goes to docto# Heard joke once: Man goes to docto -# Says he's depressed.# Says he's depressed. - -12 # Man bursts into tears. Says: 'But13 # Man bursts into tears. Says: 'But +# Heard joke once: Man goes to docto# Heard joke once: Man goes to docto +# Says he's depressed.# Says he's depressed. + +12 # Man bursts into tears. Says: 'But13 # Man bursts into tears. Says: 'But 13 14  -14 forninrange(10):15 forninrange(10): -15 print("Foo")16 print("Bar") +14 forninrange(10):15 forninrange(10): +15 print("Foo")16 print("Bar") diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_unified.svg b/tests/__snapshots__/test_snapshots/test_diff_view_unified.svg index 00d6a05..bdb5aa9 100644 --- a/tests/__snapshots__/test_snapshots/test_diff_view_unified.svg +++ b/tests/__snapshots__/test_snapshots/test_diff_view_unified.svg @@ -43,20 +43,21 @@ .terminal-r9 { fill: #f8f8f2 } .terminal-r10 { fill: #8a5258 } .terminal-r11 { fill: #dd7d7e } -.terminal-r12 { fill: #ffa6d9 } -.terminal-r13 { fill: #ffd09d;text-decoration: underline; } -.terminal-r14 { fill: #83e79c } -.terminal-r15 { fill: #4f8a65 } -.terminal-r16 { fill: #7ada94 } -.terminal-r17 { fill: #d3b7fb } -.terminal-r18 { fill: #79d693;font-style: italic; } -.terminal-r19 { fill: #8bfba7 } -.terminal-r20 { fill: #82e89d } -.terminal-r21 { fill: #acadb1 } -.terminal-r22 { fill: #d3b7fb;font-weight: bold } -.terminal-r23 { fill: #ffd09d } -.terminal-r24 { fill: #89e89d } -.terminal-r25 { fill: #83ee9e } +.terminal-r12 { fill: #ff8e8e } +.terminal-r13 { fill: #ffa6d9 } +.terminal-r14 { fill: #ffd09d;text-decoration: underline; } +.terminal-r15 { fill: #83e79c } +.terminal-r16 { fill: #4f8a65 } +.terminal-r17 { fill: #7ada94 } +.terminal-r18 { fill: #8bfba7 } +.terminal-r19 { fill: #d3b7fb } +.terminal-r20 { fill: #79d693;font-style: italic; } +.terminal-r21 { fill: #82e89d } +.terminal-r22 { fill: #acadb1 } +.terminal-r23 { fill: #d3b7fb;font-weight: bold } +.terminal-r24 { fill: #ffd09d } +.terminal-r25 { fill: #89e89d } +.terminal-r26 { fill: #83ee9e } @@ -147,23 +148,23 @@ 📄 hello2.py (+5-4) ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ 1  1  -2    defgreet(): -3    print"Hello!" -   2 defgreet(name:str): -   3 """Greet anyone""" -   4 print(f"Hello, {name}!") +2    defgreet(): +3    print"Hello!" +   2 defgreet(name:str): +   3 """Greet anyone""" +   4 print(f"Hello, {name}!") 4  5  -5    greet() -   6 greet('Will') +5    greet() +   6 greet('Will') 6  7  -7  8 # Heard joke once: Man goes to doctor. -8  9 # Says he's depressed. - -12  13 # Man bursts into tears. Says: 'But doctor... I am Pagliacci. +7  8 # Heard joke once: Man goes to doctor. +8  9 # Says he's depressed. + +12  13 # Man bursts into tears. Says: 'But doctor... I am Pagliacci. 13  14  -14  15 forninrange(10): -15     print("Foo") -    16 print("Bar") +14  15 forninrange(10): +15     print("Foo") +    16 print("Bar") diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_unified_annotations.svg b/tests/__snapshots__/test_snapshots/test_diff_view_unified_annotations.svg index ffefc71..e8cf415 100644 --- a/tests/__snapshots__/test_snapshots/test_diff_view_unified_annotations.svg +++ b/tests/__snapshots__/test_snapshots/test_diff_view_unified_annotations.svg @@ -43,20 +43,21 @@ .terminal-r9 { fill: #f8f8f2 } .terminal-r10 { fill: #8a5258 } .terminal-r11 { fill: #dd7d7e } -.terminal-r12 { fill: #ffa6d9 } -.terminal-r13 { fill: #ffd09d;text-decoration: underline; } -.terminal-r14 { fill: #83e79c } -.terminal-r15 { fill: #4f8a65 } -.terminal-r16 { fill: #7ada94 } -.terminal-r17 { fill: #d3b7fb } -.terminal-r18 { fill: #79d693;font-style: italic; } -.terminal-r19 { fill: #8bfba7 } -.terminal-r20 { fill: #82e89d } -.terminal-r21 { fill: #acadb1 } -.terminal-r22 { fill: #d3b7fb;font-weight: bold } -.terminal-r23 { fill: #ffd09d } -.terminal-r24 { fill: #89e89d } -.terminal-r25 { fill: #83ee9e } +.terminal-r12 { fill: #ff8e8e } +.terminal-r13 { fill: #ffa6d9 } +.terminal-r14 { fill: #ffd09d;text-decoration: underline; } +.terminal-r15 { fill: #83e79c } +.terminal-r16 { fill: #4f8a65 } +.terminal-r17 { fill: #7ada94 } +.terminal-r18 { fill: #8bfba7 } +.terminal-r19 { fill: #d3b7fb } +.terminal-r20 { fill: #79d693;font-style: italic; } +.terminal-r21 { fill: #82e89d } +.terminal-r22 { fill: #acadb1 } +.terminal-r23 { fill: #d3b7fb;font-weight: bold } +.terminal-r24 { fill: #ffd09d } +.terminal-r25 { fill: #89e89d } +.terminal-r26 { fill: #83ee9e } @@ -147,23 +148,23 @@ 📄 hello2.py (+5-4) ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ 1  1  -2     - defgreet(): -3     - print"Hello!" -   2  + defgreet(name:str): -   3  + """Greet anyone""" -   4  + print(f"Hello, {name}!") +2     - defgreet(): +3     - print"Hello!" +   2  + defgreet(name:str): +   3  + """Greet anyone""" +   4  + print(f"Hello, {name}!") 4  5  -5     - greet() -   6  + greet('Will') +5     - greet() +   6  + greet('Will') 6  7  -7  8 # Heard joke once: Man goes to doctor. -8  9 # Says he's depressed. - -12  13 # Man bursts into tears. Says: 'But doctor... I am Pagliacci. +7  8 # Heard joke once: Man goes to doctor. +8  9 # Says he's depressed. + +12  13 # Man bursts into tears. Says: 'But doctor... I am Pagliacci. 13  14  -14  15 forninrange(10): -15      - print("Foo") -    16  + print("Bar") +14  15 forninrange(10): +15      - print("Foo") +    16  + print("Bar") diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split.svg b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split.svg new file mode 100644 index 0000000..e9c7090 --- /dev/null +++ b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split.svg @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DiffApp + + + + + + + + + + 📄 hello2.py (+5-4) +╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + +defgreet():defgreet(name:str): +print"Hello!""""Greet anyone""" +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲print(f"Hello, {name}! +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲") + +greet()greet('Will') + +# Heard joke once: Man goe# Heard joke once: Man goe +s to doctor.s to doctor. +# Says he's depressed.# Says he's depressed. + +12 # Man bursts into tears. 13 # Man bursts into tears.  +Says: 'But doctor... I amSays: 'But doctor... I am + Pagliacci. Pagliacci. +13 14  +14 forninrange(10):15 forninrange(10): +15 print("Foo")16 print("Bar") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split_annotations.svg b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split_annotations.svg new file mode 100644 index 0000000..3134f54 --- /dev/null +++ b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_split_annotations.svg @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DiffApp + + + + + + + + + + 📄 hello2.py (+5-4) +╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + - defgreet(): + defgreet(name:str): + - print"Hello!" + """Greet anyone""" +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲ + print(f"Hello, {name +╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲╲ ↪ }!") + + - greet() + greet('Will') + +# Heard joke once: Man g# Heard joke once: Man g + ↪ oes to doctor. ↪ oes to doctor. +# Says he's depressed.# Says he's depressed. + +12 # Man bursts into tears13 # Man bursts into tears + ↪ . Says: 'But doctor...  ↪ . Says: 'But doctor...  + ↪ I am Pagliacci. ↪ I am Pagliacci. +13 14  +14 forninrange(10):15 forninrange(10): +15  - print("Foo")16  + print("Bar") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified.svg b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified.svg new file mode 100644 index 0000000..fbbca19 --- /dev/null +++ b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified.svg @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DiffApp + + + + + + + + + + 📄 hello2.py (+5-4) +╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + 1  +defgreet(): +print"Hello!" + 2 defgreet(name:str): + 3 """Greet anyone""" + 4 print(f"Hello, {name}!") + 5  +greet() + 6 greet('Will') + 7  + 8 # Heard joke once: Man goes to doctor. + 9 # Says he's depressed. + +12  13 # Man bursts into tears. Says: 'But doctor... I am  +Pagliacci. +13  14  +14  15 forninrange(10): +15 print("Foo") + 16 print("Bar") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified_annotations.svg b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified_annotations.svg new file mode 100644 index 0000000..642c3ab --- /dev/null +++ b/tests/__snapshots__/test_snapshots/test_diff_view_wrap_unified_annotations.svg @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DiffApp + + + + + + + + + + 📄 hello2.py (+5-4) +╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + 1  + - defgreet(): + - print"Hello!" + 2  + defgreet(name:str): + 3  + """Greet anyone""" + 4  + print(f"Hello, {name}!") + 5  + - greet() + 6  + greet('Will') + 7  + 8 # Heard joke once: Man goes to doctor. + 9 # Says he's depressed. + +12  13 # Man bursts into tears. Says: 'But doctor... I a + ↪ m Pagliacci. +13  14  +14  15 forninrange(10): +15  - print("Foo") + 16  + print("Bar") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 664bf2c..47f184d 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -71,3 +71,46 @@ def run_before(pilot: Pilot): pilot.app.query_one(DiffView).annotations = True assert snap_compare(DiffApp(), run_before=run_before) + + +WRAP_TERMINAL_SIZE = (60, 50) + + +def test_diff_view_wrap_split(snap_compare): + def run_before(pilot: Pilot): + pilot.app.query_one(DiffView).wrap = True + + assert snap_compare( + DiffApp(), run_before=run_before, terminal_size=WRAP_TERMINAL_SIZE + ) + + +def test_diff_view_wrap_split_annotations(snap_compare): + def run_before(pilot: Pilot): + pilot.app.query_one(DiffView).wrap = True + pilot.app.query_one(DiffView).annotations = True + + assert snap_compare( + DiffApp(), run_before=run_before, terminal_size=WRAP_TERMINAL_SIZE + ) + + +def test_diff_view_wrap_unified(snap_compare): + def run_before(pilot: Pilot): + pilot.app.query_one(DiffView).wrap = True + pilot.app.query_one(DiffView).split = False + + assert snap_compare( + DiffApp(), run_before=run_before, terminal_size=WRAP_TERMINAL_SIZE + ) + + +def test_diff_view_wrap_unified_annotations(snap_compare): + def run_before(pilot: Pilot): + pilot.app.query_one(DiffView).wrap = True + pilot.app.query_one(DiffView).split = False + pilot.app.query_one(DiffView).annotations = True + + assert snap_compare( + DiffApp(), run_before=run_before, terminal_size=WRAP_TERMINAL_SIZE + )