diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c279f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +dunk/__pycache__ +dist \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59ee130 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025 Darren Burns + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58b7295 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# dunk + +Pipe your `git diff` output into `dunk` to make it prettier! + +![image](https://user-images.githubusercontent.com/5740731/162084469-718a8b48-a176-4657-961a-f45e157ff562.png) + +> ⚠️ This project is **very** early stages - expect crashes, bugs, and confusing output! + +## Quick Start + +I recommend you install using `pipx`, which will allow you to use `dunk` from anywhere. + +``` +pipx install dunk +``` + +## Basic Usage + +Pipe the output of `git diff` into `dunk`: + +``` +git diff | dunk +``` + +or add it to git as an alias: +``` +git config --global alias.dunk '!git diff | dunk' +``` + +### Paging + +You can pipe output from `dunk` into a pager such as `less`: + +``` +git diff | dunk | less -R +``` diff --git a/dunk/__init__.py b/dunk/__init__.py new file mode 100644 index 0000000..331dfae --- /dev/null +++ b/dunk/__init__.py @@ -0,0 +1 @@ +__version__ = "0.5.0b0" diff --git a/dunk/dunk.py b/dunk/dunk.py new file mode 100644 index 0000000..e564938 --- /dev/null +++ b/dunk/dunk.py @@ -0,0 +1,520 @@ +import functools +import os +import sys +from collections import defaultdict +from difflib import SequenceMatcher +from pathlib import Path +from typing import Dict, List, cast, Iterable, Tuple, TypeVar, Optional, Set, NamedTuple + +from rich.align import Align +from rich.color import blend_rgb, Color +from rich.color_triplet import ColorTriplet +from rich.console import Console +from rich.segment import Segment, SegmentLines +from rich.style import Style +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text +from rich.theme import Theme +from unidiff import PatchSet +from unidiff.patch import Hunk, Line, PatchedFile + +import dunk +from dunk.renderables import ( + PatchSetHeader, + RemovedFileBody, + BinaryFileBody, + PatchedFileHeader, + OnlyRenamedFileBody, +) + +MONOKAI_LIGHT_ACCENT = Color.from_rgb(62, 64, 54).triplet.hex +MONOKAI_BACKGROUND = Color.from_rgb(red=39, green=40, blue=34) +DUNK_BG_HEX = "#0d0f0b" +MONOKAI_BG_HEX = MONOKAI_BACKGROUND.triplet.hex + +T = TypeVar("T") + +theme = Theme( + { + "hatched": f"{MONOKAI_BG_HEX} on {DUNK_BG_HEX}", + "renamed": f"cyan", + "border": MONOKAI_LIGHT_ACCENT, + } +) +force_width, _ = os.get_terminal_size(2) +console = Console(force_terminal=True, width=force_width, theme=theme) + + +def find_git_root() -> Path: + cwd = Path.cwd() + if (cwd / ".git").exists(): + return Path.cwd() + + for directory in cwd.parents: + if (directory / ".git").exists(): + return directory + + return cwd + + +# +class ContiguousStreak(NamedTuple): + """A single hunk can have multiple streaks of additions/removals of different length""" + + streak_row_start: int + streak_length: int + + +def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for first value.""" + iter_values = iter(values) + try: + value = next(iter_values) + except StopIteration: + return + yield True, value + for value in iter_values: + yield False, value + + +def main(): + try: + _run_dunk() + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output + # to devnull to avoid another BrokenPipeError at shutdown + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(1) + + +def _run_dunk(): + input = sys.stdin.readlines() + diff = "".join(input) + patch_set: PatchSet = PatchSet(diff) + + project_root: Path = find_git_root() + + console.print( + PatchSetHeader( + file_modifications=len(patch_set.modified_files), + file_additions=len(patch_set.added_files), + file_removals=len(patch_set.removed_files), + line_additions=patch_set.added, + line_removals=patch_set.removed, + ) + ) + + for is_first, patch in loop_first(patch_set): + patch = cast(PatchedFile, patch) + console.print(PatchedFileHeader(patch)) + + if patch.is_removed_file: + console.print(RemovedFileBody()) + continue + + # The file wasn't removed, so we can open it. + target_file = project_root / patch.path + + if patch.is_binary_file: + console.print(BinaryFileBody(size_in_bytes=target_file.stat().st_size)) + continue + + if patch.is_rename and not patch.added and not patch.removed: + console.print(OnlyRenamedFileBody(patch)) + + source_lineno = 1 + target_lineno = 1 + + target_code = target_file.read_text() + target_lines = target_code.splitlines(keepends=True) + source_lineno_max = len(target_lines) - patch.added + patch.removed + + source_hunk_cache: Dict[int, Hunk] = {hunk.source_start: hunk for hunk in patch} + source_reconstructed: List[str] = [] + + while source_lineno <= source_lineno_max: + hunk = source_hunk_cache.get(source_lineno) + if hunk: + # This line can be reconstructed in source from the hunk + lines = [line.value for line in hunk.source_lines()] + source_reconstructed.extend(lines) + source_lineno += hunk.source_length + target_lineno += hunk.target_length + else: + # The line isn't in the diff, pull over current target lines + target_line_index = target_lineno - 1 + + line = target_lines[target_line_index] + source_reconstructed.append(line) + + source_lineno += 1 + target_lineno += 1 + + source_code = "".join(source_reconstructed) + lexer = Syntax.guess_lexer(patch.path) + + for is_first_hunk, hunk in loop_first(patch): + # Use difflib to examine differences between each line of the hunk + # Target essentially means the additions/green text in diff + target_line_range = ( + hunk.target_start, + hunk.target_length + hunk.target_start - 1, + ) + source_line_range = ( + hunk.source_start, + hunk.source_length + hunk.source_start - 1, + ) + + source_syntax = Syntax( + source_code, + lexer=lexer, + line_range=source_line_range, + line_numbers=True, + indent_guides=True, + ) + target_syntax = Syntax( + target_code, + lexer=lexer, + line_range=target_line_range, + line_numbers=True, + indent_guides=True, + ) + source_removed_linenos = set() + target_added_linenos = set() + + context_linenos = [] + for line in hunk: + line = cast(Line, line) + if line.source_line_no and line.is_removed: + source_removed_linenos.add(line.source_line_no) + elif line.target_line_no and line.is_added: + target_added_linenos.add(line.target_line_no) + elif line.is_context: + context_linenos.append((line.source_line_no, line.target_line_no)) + + # To ensure that lines are aligned on the left and right in the split + # diff, we need to add some padding above the lines the amount of padding + # can be calculated by *changes* in the difference in offset between the + # source and target context line numbers. When a change occurs, we note + # how much the change was, and that's how much padding we need to add. If + # the change in source - target context line numbers is positive, + # we pad above the target. If it's negative, we pad above the source line. + source_lineno_to_padding = {} + target_lineno_to_padding = {} + + first_source_context, first_target_context = next( + iter(context_linenos), (0, 0) + ) + current_delta = first_source_context - first_target_context + for source_lineno, target_lineno in context_linenos: + delta = source_lineno - target_lineno + change_in_delta = current_delta - delta + pad_amount = abs(change_in_delta) + if change_in_delta > 0: + source_lineno_to_padding[source_lineno] = pad_amount + elif change_in_delta < 0: + target_lineno_to_padding[target_lineno] = pad_amount + current_delta = delta + + # Track which source and target lines are aligned and should be intraline + # diffed Work out row number of lines in each side of the diff. Row + # number is how far from the top of the syntax snippet we are. A line in + # the source and target with the same row numbers will be aligned in the + # diff (their line numbers in the source code may be different, though). + # There can be gaps in row numbers too, since sometimes we add padding + # above rows to ensure the source and target diffs are aligned with each + # other. + + # Map row numbers to lines + source_lines_by_row_index: Dict[int, Line] = {} + target_lines_by_row_index: Dict[int, Line] = {} + + # We have to track the length of contiguous streaks of altered lines, as + # we can only provide intraline diffing to aligned streaks of identical + # length. If they are different lengths it is almost impossible to align + # the contiguous streaks without falling back to an expensive heuristic. + # If a source line and a target line map to equivalent ContiguousStreaks, + # then we can safely apply intraline highlighting to them. + source_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {} + + accumulated_source_padding = 0 + + contiguous_streak_row_start = 0 + contiguous_streak_length = 0 + for i, line in enumerate(hunk.source_lines()): + if line.is_removed: + if contiguous_streak_length == 0: + contiguous_streak_row_start = i + contiguous_streak_length += 1 + else: + # We've reached the end of the streak, so we'll associate all the + # lines in the streak with it for later lookup. + for row_index in range( + contiguous_streak_row_start, + contiguous_streak_row_start + contiguous_streak_length, + ): + source_row_to_contiguous_streak_length[row_index] = ( + ContiguousStreak( + streak_row_start=contiguous_streak_row_start, + streak_length=contiguous_streak_length, + ) + ) + contiguous_streak_length = 0 + + lineno = hunk.source_start + i + this_line_padding = source_lineno_to_padding.get(lineno, 0) + accumulated_source_padding += this_line_padding + row_number = i + accumulated_source_padding + source_lines_by_row_index[row_number] = line + + # TODO: Factor out this code into a function, we're doing the same thing + # for all lines in both source and target hunks. + target_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {} + + accumulated_target_padding = 0 + + target_streak_row_start = 0 + target_streak_length = 0 + for i, line in enumerate(hunk.target_lines()): + if line.is_added: + if target_streak_length == 0: + target_streak_row_start = i + target_streak_length += 1 + else: + for row_index in range( + target_streak_row_start, + target_streak_row_start + target_streak_length, + ): + target_row_to_contiguous_streak_length[row_index] = ( + ContiguousStreak( + streak_row_start=target_streak_row_start, + streak_length=target_streak_length, + ) + ) + target_streak_length = 0 + + lineno = hunk.target_start + i + this_line_padding = target_lineno_to_padding.get(lineno, 0) + accumulated_target_padding += this_line_padding + row_number = i + accumulated_target_padding + target_lines_by_row_index[row_number] = line + + row_number_to_deletion_ranges = defaultdict(list) + row_number_to_insertion_ranges = defaultdict(list) + + # Collect intraline diff info for highlighting + for row_number, source_line in source_lines_by_row_index.items(): + source_streak = source_row_to_contiguous_streak_length.get(row_number) + target_streak = target_row_to_contiguous_streak_length.get(row_number) + + # TODO: We need to work out the offsets to ensure that we look up + # the correct target and source row streaks to compare. Will probably + # need to append accumulated padding to row numbers + + # print(padded_source_row, padded_target_row, source_line.value) + # if source_streak: + # print(f"sourcestreak {row_number}", source_streak) + # if target_streak: + # print(f"targetstreak {row_number}", target_streak) + + intraline_enabled = ( + source_streak is not None + and target_streak is not None + and source_streak.streak_length == target_streak.streak_length + ) + if not intraline_enabled: + # print(f"skipping row {row_number}") + continue + + target_line = target_lines_by_row_index.get(row_number) + + are_diffable = ( + source_line + and target_line + and source_line.is_removed + and target_line.is_added + ) + if target_line and are_diffable: + matcher = SequenceMatcher( + None, source_line.value, target_line.value + ) + opcodes = matcher.get_opcodes() + ratio = matcher.ratio() + if ratio > 0.5: + for tag, i1, i2, j1, j2 in opcodes: + if tag == "delete": + row_number_to_deletion_ranges[row_number].append( + (i1, i2) + ) + elif tag == "insert": + row_number_to_insertion_ranges[row_number].append( + (j1, j2) + ) + elif tag == "replace": + row_number_to_deletion_ranges[row_number].append( + (i1, i2) + ) + row_number_to_insertion_ranges[row_number].append( + (j1, j2) + ) + + source_syntax_lines: List[List[Segment]] = console.render_lines( + source_syntax + ) + target_syntax_lines = console.render_lines(target_syntax) + + highlighted_source_lines = highlight_and_align_lines_in_hunk( + hunk.source_start, + source_removed_linenos, + source_syntax_lines, + ColorTriplet(255, 0, 0), + source_lineno_to_padding, + dict(row_number_to_deletion_ranges), + gutter_size=len(str(source_lineno_max)) + 2, + ) + highlighted_target_lines = highlight_and_align_lines_in_hunk( + hunk.target_start, + target_added_linenos, + target_syntax_lines, + ColorTriplet(0, 255, 0), + target_lineno_to_padding, + dict(row_number_to_insertion_ranges), + gutter_size=len(str(len(target_lines) + 1)) + 2, + ) + + table = Table.grid() + table.add_column(style="on #0d0f0b") + table.add_column(style="on #0d0f0b") + table.add_row( + SegmentLines(highlighted_source_lines, new_lines=True), + SegmentLines(highlighted_target_lines, new_lines=True), + ) + + hunk_header_style = f"{MONOKAI_BACKGROUND.triplet.hex} on #0d0f0b" + hunk_header = ( + f"[on #0d0f0b dim]@@ [red]-{hunk.source_start},{hunk.source_length}[/] " + f"[green]+{hunk.target_start},{hunk.target_length}[/] " + f"[dim]@@ {hunk.section_header or ''}[/]" + ) + console.rule(hunk_header, characters="╲", style=hunk_header_style) + console.print(table) + + # TODO: File name indicator at bottom of file, if diff is larger than terminal height. + console.rule(style="border", characters="▔") + + console.print( + Align.right( + f"[blue]/[/][red]/[/][green]/[/] [dim]dunk {dunk.__version__}[/] " + ) + ) + # console.save_svg("dunk.svg", title="Diff output generated using Dunk") + + +def highlight_and_align_lines_in_hunk( + start_lineno: int, + highlight_linenos: Set[Optional[int]], + syntax_hunk_lines: List[List[Segment]], + blend_colour: ColorTriplet, + lines_to_pad_above: Dict[int, int], + highlight_ranges: Dict[int, Tuple[int, int]], + gutter_size: int, +): + highlighted_lines = [] + + # Apply diff-related highlighting to lines + for index, line in enumerate(syntax_hunk_lines): + lineno = index + start_lineno + + if lineno in highlight_linenos: + new_line = [] + segment_number = 0 + for segment in line: + style: Style + text, style, control = segment + + if style: + if style.bgcolor: + bgcolor_triplet = style.bgcolor.triplet + cross_fade = 0.85 + new_bgcolour_triplet = blend_rgb_cached( + blend_colour, bgcolor_triplet, cross_fade=cross_fade + ) + new_bgcolor = Color.from_triplet(new_bgcolour_triplet) + else: + new_bgcolor = None + + if style.color and segment_number == 1: + new_triplet = blend_rgb_cached( + blend_rgb_cached( + blend_colour, style.color.triplet, cross_fade=0.5 + ), + ColorTriplet(255, 255, 255), + cross_fade=0.4, + ) + new_color = Color.from_triplet(new_triplet) + else: + new_color = None + + overlay_style = Style.from_color( + color=new_color, bgcolor=new_bgcolor + ) + updated_style = style + overlay_style + new_line.append(Segment(text, updated_style, control)) + else: + new_line.append(segment) + segment_number += 1 + else: + new_line = line[:] + + # Pad above the line if required + pad = lines_to_pad_above.get(lineno, 0) + for i in range(pad): + highlighted_lines.append( + [ + Segment( + "╲" * console.width, Style.from_color(color=MONOKAI_BACKGROUND) + ) + ] + ) + + # Finally, apply the intraline diff highlighting for this line if possible + if index in highlight_ranges: + line_as_text = Text.assemble( + *((text, style) for text, style, control in new_line), end="" + ) + intraline_bgcolor = Color.from_triplet( + blend_rgb_cached( + blend_colour, MONOKAI_BACKGROUND.triplet, cross_fade=0.6 + ) + ) + intraline_color = Color.from_triplet( + blend_rgb_cached( + intraline_bgcolor.triplet, + Color.from_rgb(255, 255, 255).triplet, + cross_fade=0.8, + ) + ) + for start, end in highlight_ranges.get(index): + line_as_text.stylize( + Style.from_color(color=intraline_color, bgcolor=intraline_bgcolor), + start=start + gutter_size + 1, + end=end + gutter_size + 1, + ) + new_line = list(console.render(line_as_text)) + highlighted_lines.append(new_line) + return highlighted_lines + + +@functools.lru_cache(maxsize=128) +def blend_rgb_cached( + colour1: ColorTriplet, colour2: ColorTriplet, cross_fade: float = 0.6 +) -> ColorTriplet: + return blend_rgb(colour1, colour2, cross_fade=cross_fade) + + +if __name__ == "__main__": + main() diff --git a/dunk/renderables.py b/dunk/renderables.py new file mode 100644 index 0000000..6c5b01f --- /dev/null +++ b/dunk/renderables.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from pathlib import Path + +from rich.align import Align +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.markup import escape +from rich.rule import Rule +from rich.segment import Segment +from rich.table import Table +from rich.text import Text +from unidiff import PatchedFile + +from dunk.underline_bar import UnderlineBar + + +def simple_pluralise(word: str, number: int) -> str: + if number == 1: + return word + else: + return word + "s" + + +@dataclass +class PatchSetHeader: + file_modifications: int + file_additions: int + file_removals: int + line_additions: int + line_removals: int + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + if self.file_modifications: + yield Align.center( + f"[blue]{self.file_modifications} {simple_pluralise('file', self.file_modifications)} changed" + ) + if self.file_additions: + yield Align.center( + f"[green]{self.file_additions} {simple_pluralise('file', self.file_additions)} added" + ) + if self.file_removals: + yield Align.center( + f"[red]{self.file_removals} {simple_pluralise('file', self.file_removals)} removed" + ) + + bar_width = console.width // 5 + changed_lines = max(1, self.line_additions + self.line_removals) + added_lines_ratio = self.line_additions / changed_lines + + line_changes_summary = Table.grid() + line_changes_summary.add_column() + line_changes_summary.add_column() + line_changes_summary.add_column() + line_changes_summary.add_row( + f"[bold green]+{self.line_additions} ", + UnderlineBar( + highlight_range=(0, added_lines_ratio * bar_width), + highlight_style="green", + background_style="red", + width=bar_width, + ), + f" [bold red]-{self.line_removals}", + ) + + bar_hpad = len(str(self.line_additions)) + len(str(self.line_removals)) + 4 + yield Align.center(line_changes_summary, width=bar_width + bar_hpad) + yield Segment.line() + + +class RemovedFileBody: + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Rule(characters="╲", style="hatched") + yield Rule(" [red]File was removed ", characters="╲", style="hatched") + yield Rule(characters="╲", style="hatched") + yield Rule(style="border", characters="▔") + + +@dataclass +class BinaryFileBody: + size_in_bytes: int + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Rule(characters="╲", style="hatched") + yield Rule( + Text(f" File is binary · {self.size_in_bytes} bytes ", style="blue"), + characters="╲", + style="hatched", + ) + yield Rule(characters="╲", style="hatched") + yield Rule(style="border", characters="▔") + + +class PatchedFileHeader: + def __init__(self, patch: PatchedFile): + self.patch = patch + if patch.is_rename: + self.path_prefix = ( + f"[dim][s]{escape(Path(patch.source_file).name)}[/] → [/]" + ) + elif patch.is_added_file: + self.path_prefix = f"[bold green]Added [/]" + else: + self.path_prefix = "" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Rule( + f"{self.path_prefix}[b]{escape(self.patch.path)}[/] ([green]{self.patch.added} additions[/], " + f"[red]{self.patch.removed} removals[/])", + style="border", + characters="▁", + ) + + +class OnlyRenamedFileBody: + """Represents a file that was renamed but the content was not changed.""" + + def __init__(self, patch: PatchedFile): + self.patch = patch + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Rule(characters="╲", style="hatched") + yield Rule(" [blue]File was only renamed ", characters="╲", style="hatched") + yield Rule(characters="╲", style="hatched") + yield Rule(style="border", characters="▔") diff --git a/dunk/underline_bar.py b/dunk/underline_bar.py new file mode 100644 index 0000000..e0afdca --- /dev/null +++ b/dunk/underline_bar.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console, RenderResult +from rich.style import StyleType +from rich.text import Text + + +class UnderlineBar: + """Thin horizontal bar with a portion highlighted. + + Args: + highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight) + highlight_style (StyleType): The style of the highlighted range of the bar. + background_style (StyleType): The style of the non-highlighted range(s) of the bar. + width (int, optional): The width of the bar, or ``None`` to fill available width. + """ + + def __init__( + self, + highlight_range: tuple[float, float] = (0, 0), + highlight_style: StyleType = "magenta", + background_style: StyleType = "grey37", + clickable_ranges: dict[str, tuple[int, int]] | None = None, + width: int | None = None, + ) -> None: + self.highlight_range = highlight_range + self.highlight_style = highlight_style + self.background_style = background_style + self.clickable_ranges = clickable_ranges or {} + self.width = width + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + highlight_style = console.get_style(self.highlight_style) + background_style = console.get_style(self.background_style) + + half_bar_right = "╸" + half_bar_left = "╺" + bar = "━" + + width = self.width or options.max_width + start, end = self.highlight_range + + start = max(start, 0) + end = min(end, width) + + output_bar = Text("", end="") + + if start == end == 0 or end < 0 or start > end: + output_bar.append(Text(bar * width, style=background_style, end="")) + yield output_bar + return + + # Round start and end to nearest half + start = round(start * 2) / 2 + end = round(end * 2) / 2 + + # Check if we start/end on a number that rounds to a .5 + half_start = start - int(start) > 0 + half_end = end - int(end) > 0 + + # Initial non-highlighted portion of bar + output_bar.append( + Text(bar * (int(start - 0.5)), style=background_style, end="") + ) + if not half_start and start > 0: + output_bar.append(Text(half_bar_right, style=background_style, end="")) + + # The highlighted portion + bar_width = int(end) - int(start) + if half_start: + output_bar.append( + Text( + half_bar_left + bar * (bar_width - 1), style=highlight_style, end="" + ) + ) + else: + output_bar.append(Text(bar * bar_width, style=highlight_style, end="")) + if half_end: + output_bar.append(Text(half_bar_right, style=highlight_style, end="")) + + # The non-highlighted tail + if not half_end and end - width != 0: + output_bar.append(Text(half_bar_left, style=background_style, end="")) + output_bar.append( + Text(bar * (int(width) - int(end) - 1), style=background_style, end="") + ) + + # Fire actions when certain ranges are clicked (e.g. for tabs) + for range_name, (start, end) in self.clickable_ranges.items(): + output_bar.apply_meta( + {"@click": f"range_clicked('{range_name}')"}, start, end + ) + + yield output_bar diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..017ce3a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "dunk" +version = "0.5.0b0" +description = "Beautiful side-by-side diffs in your terminal" +authors = [ + {name = "Darren Burns", email = "darrenb900@gmail.com"} +] +readme = "README.md" +license = {file = "LICENSE"} +keywords = ["diff", "terminal", "side-by-side", "git", "cli", "dev-tools"] +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +requires-python = ">=3.11" +dependencies = [ + "unidiff>=0.7", + "rich>=12.1.0" +] + +[project.scripts] +dunk = "dunk.dunk:main" + +[project.urls] +homepage = "https://github.com/darrenburns/dunk" +repository = "https://github.com/darrenburns/dunk" +issues = "https://github.com/darrenburns/dunk/issues" +documentation = "https://github.com/darrenburns/dunk/blob/main/README.md" + +[tool.uv] +dev-dependencies = [ + "pytest", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["dunk"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f2344e0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,129 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dunk" +version = "0.5.0b0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, + { name = "unidiff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "rich", specifier = ">=12.1.0" }, + { name = "unidiff", specifier = ">=0.7" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "unidiff" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386 }, +]