├── .gitignore ├── LICENSE ├── README.md ├── dunk ├── __init__.py ├── dunk.py ├── renderables.py └── underline_bar.py ├── pyproject.toml └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dunk/__pycache__ 3 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Darren Burns 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dunk 2 | 3 | Pipe your `git diff` output into `dunk` to make it prettier! 4 | 5 | ![image](https://user-images.githubusercontent.com/5740731/162084469-718a8b48-a176-4657-961a-f45e157ff562.png) 6 | 7 | > ⚠️ This project is **very** early stages - expect crashes, bugs, and confusing output! 8 | 9 | ## Quick Start 10 | 11 | I recommend you install using `pipx`, which will allow you to use `dunk` from anywhere. 12 | 13 | ``` 14 | pipx install dunk 15 | ``` 16 | 17 | ## Basic Usage 18 | 19 | Pipe the output of `git diff` into `dunk`: 20 | 21 | ``` 22 | git diff | dunk 23 | ``` 24 | 25 | or add it to git as an alias: 26 | ``` 27 | git config --global alias.dunk '!git diff | dunk' 28 | ``` 29 | 30 | ### Paging 31 | 32 | You can pipe output from `dunk` into a pager such as `less`: 33 | 34 | ``` 35 | git diff | dunk | less -R 36 | ``` 37 | -------------------------------------------------------------------------------- /dunk/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.0b0" 2 | -------------------------------------------------------------------------------- /dunk/dunk.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import sys 4 | from collections import defaultdict 5 | from difflib import SequenceMatcher 6 | from pathlib import Path 7 | from typing import Dict, List, cast, Iterable, Tuple, TypeVar, Optional, Set, NamedTuple 8 | 9 | from rich.align import Align 10 | from rich.color import blend_rgb, Color 11 | from rich.color_triplet import ColorTriplet 12 | from rich.console import Console 13 | from rich.segment import Segment, SegmentLines 14 | from rich.style import Style 15 | from rich.syntax import Syntax 16 | from rich.table import Table 17 | from rich.text import Text 18 | from rich.theme import Theme 19 | from unidiff import PatchSet 20 | from unidiff.patch import Hunk, Line, PatchedFile 21 | 22 | import dunk 23 | from dunk.renderables import ( 24 | PatchSetHeader, 25 | RemovedFileBody, 26 | BinaryFileBody, 27 | PatchedFileHeader, 28 | OnlyRenamedFileBody, 29 | ) 30 | 31 | MONOKAI_LIGHT_ACCENT = Color.from_rgb(62, 64, 54).triplet.hex 32 | MONOKAI_BACKGROUND = Color.from_rgb(red=39, green=40, blue=34) 33 | DUNK_BG_HEX = "#0d0f0b" 34 | MONOKAI_BG_HEX = MONOKAI_BACKGROUND.triplet.hex 35 | 36 | T = TypeVar("T") 37 | 38 | theme = Theme( 39 | { 40 | "hatched": f"{MONOKAI_BG_HEX} on {DUNK_BG_HEX}", 41 | "renamed": f"cyan", 42 | "border": MONOKAI_LIGHT_ACCENT, 43 | } 44 | ) 45 | force_width, _ = os.get_terminal_size(2) 46 | console = Console(force_terminal=True, width=force_width, theme=theme) 47 | 48 | 49 | def find_git_root() -> Path: 50 | cwd = Path.cwd() 51 | if (cwd / ".git").exists(): 52 | return Path.cwd() 53 | 54 | for directory in cwd.parents: 55 | if (directory / ".git").exists(): 56 | return directory 57 | 58 | return cwd 59 | 60 | 61 | # 62 | class ContiguousStreak(NamedTuple): 63 | """A single hunk can have multiple streaks of additions/removals of different length""" 64 | 65 | streak_row_start: int 66 | streak_length: int 67 | 68 | 69 | def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: 70 | """Iterate and generate a tuple with a flag for first value.""" 71 | iter_values = iter(values) 72 | try: 73 | value = next(iter_values) 74 | except StopIteration: 75 | return 76 | yield True, value 77 | for value in iter_values: 78 | yield False, value 79 | 80 | 81 | def main(): 82 | try: 83 | _run_dunk() 84 | except BrokenPipeError: 85 | # Python flushes standard streams on exit; redirect remaining output 86 | # to devnull to avoid another BrokenPipeError at shutdown 87 | devnull = os.open(os.devnull, os.O_WRONLY) 88 | os.dup2(devnull, sys.stdout.fileno()) 89 | sys.exit(1) 90 | except KeyboardInterrupt: 91 | sys.exit(1) 92 | 93 | 94 | def _run_dunk(): 95 | input = sys.stdin.readlines() 96 | diff = "".join(input) 97 | patch_set: PatchSet = PatchSet(diff) 98 | 99 | project_root: Path = find_git_root() 100 | 101 | console.print( 102 | PatchSetHeader( 103 | file_modifications=len(patch_set.modified_files), 104 | file_additions=len(patch_set.added_files), 105 | file_removals=len(patch_set.removed_files), 106 | line_additions=patch_set.added, 107 | line_removals=patch_set.removed, 108 | ) 109 | ) 110 | 111 | for is_first, patch in loop_first(patch_set): 112 | patch = cast(PatchedFile, patch) 113 | console.print(PatchedFileHeader(patch)) 114 | 115 | if patch.is_removed_file: 116 | console.print(RemovedFileBody()) 117 | continue 118 | 119 | # The file wasn't removed, so we can open it. 120 | target_file = project_root / patch.path 121 | 122 | if patch.is_binary_file: 123 | console.print(BinaryFileBody(size_in_bytes=target_file.stat().st_size)) 124 | continue 125 | 126 | if patch.is_rename and not patch.added and not patch.removed: 127 | console.print(OnlyRenamedFileBody(patch)) 128 | 129 | source_lineno = 1 130 | target_lineno = 1 131 | 132 | target_code = target_file.read_text() 133 | target_lines = target_code.splitlines(keepends=True) 134 | source_lineno_max = len(target_lines) - patch.added + patch.removed 135 | 136 | source_hunk_cache: Dict[int, Hunk] = {hunk.source_start: hunk for hunk in patch} 137 | source_reconstructed: List[str] = [] 138 | 139 | while source_lineno <= source_lineno_max: 140 | hunk = source_hunk_cache.get(source_lineno) 141 | if hunk: 142 | # This line can be reconstructed in source from the hunk 143 | lines = [line.value for line in hunk.source_lines()] 144 | source_reconstructed.extend(lines) 145 | source_lineno += hunk.source_length 146 | target_lineno += hunk.target_length 147 | else: 148 | # The line isn't in the diff, pull over current target lines 149 | target_line_index = target_lineno - 1 150 | 151 | line = target_lines[target_line_index] 152 | source_reconstructed.append(line) 153 | 154 | source_lineno += 1 155 | target_lineno += 1 156 | 157 | source_code = "".join(source_reconstructed) 158 | lexer = Syntax.guess_lexer(patch.path) 159 | 160 | for is_first_hunk, hunk in loop_first(patch): 161 | # Use difflib to examine differences between each line of the hunk 162 | # Target essentially means the additions/green text in diff 163 | target_line_range = ( 164 | hunk.target_start, 165 | hunk.target_length + hunk.target_start - 1, 166 | ) 167 | source_line_range = ( 168 | hunk.source_start, 169 | hunk.source_length + hunk.source_start - 1, 170 | ) 171 | 172 | source_syntax = Syntax( 173 | source_code, 174 | lexer=lexer, 175 | line_range=source_line_range, 176 | line_numbers=True, 177 | indent_guides=True, 178 | ) 179 | target_syntax = Syntax( 180 | target_code, 181 | lexer=lexer, 182 | line_range=target_line_range, 183 | line_numbers=True, 184 | indent_guides=True, 185 | ) 186 | source_removed_linenos = set() 187 | target_added_linenos = set() 188 | 189 | context_linenos = [] 190 | for line in hunk: 191 | line = cast(Line, line) 192 | if line.source_line_no and line.is_removed: 193 | source_removed_linenos.add(line.source_line_no) 194 | elif line.target_line_no and line.is_added: 195 | target_added_linenos.add(line.target_line_no) 196 | elif line.is_context: 197 | context_linenos.append((line.source_line_no, line.target_line_no)) 198 | 199 | # To ensure that lines are aligned on the left and right in the split 200 | # diff, we need to add some padding above the lines the amount of padding 201 | # can be calculated by *changes* in the difference in offset between the 202 | # source and target context line numbers. When a change occurs, we note 203 | # how much the change was, and that's how much padding we need to add. If 204 | # the change in source - target context line numbers is positive, 205 | # we pad above the target. If it's negative, we pad above the source line. 206 | source_lineno_to_padding = {} 207 | target_lineno_to_padding = {} 208 | 209 | first_source_context, first_target_context = next( 210 | iter(context_linenos), (0, 0) 211 | ) 212 | current_delta = first_source_context - first_target_context 213 | for source_lineno, target_lineno in context_linenos: 214 | delta = source_lineno - target_lineno 215 | change_in_delta = current_delta - delta 216 | pad_amount = abs(change_in_delta) 217 | if change_in_delta > 0: 218 | source_lineno_to_padding[source_lineno] = pad_amount 219 | elif change_in_delta < 0: 220 | target_lineno_to_padding[target_lineno] = pad_amount 221 | current_delta = delta 222 | 223 | # Track which source and target lines are aligned and should be intraline 224 | # diffed Work out row number of lines in each side of the diff. Row 225 | # number is how far from the top of the syntax snippet we are. A line in 226 | # the source and target with the same row numbers will be aligned in the 227 | # diff (their line numbers in the source code may be different, though). 228 | # There can be gaps in row numbers too, since sometimes we add padding 229 | # above rows to ensure the source and target diffs are aligned with each 230 | # other. 231 | 232 | # Map row numbers to lines 233 | source_lines_by_row_index: Dict[int, Line] = {} 234 | target_lines_by_row_index: Dict[int, Line] = {} 235 | 236 | # We have to track the length of contiguous streaks of altered lines, as 237 | # we can only provide intraline diffing to aligned streaks of identical 238 | # length. If they are different lengths it is almost impossible to align 239 | # the contiguous streaks without falling back to an expensive heuristic. 240 | # If a source line and a target line map to equivalent ContiguousStreaks, 241 | # then we can safely apply intraline highlighting to them. 242 | source_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {} 243 | 244 | accumulated_source_padding = 0 245 | 246 | contiguous_streak_row_start = 0 247 | contiguous_streak_length = 0 248 | for i, line in enumerate(hunk.source_lines()): 249 | if line.is_removed: 250 | if contiguous_streak_length == 0: 251 | contiguous_streak_row_start = i 252 | contiguous_streak_length += 1 253 | else: 254 | # We've reached the end of the streak, so we'll associate all the 255 | # lines in the streak with it for later lookup. 256 | for row_index in range( 257 | contiguous_streak_row_start, 258 | contiguous_streak_row_start + contiguous_streak_length, 259 | ): 260 | source_row_to_contiguous_streak_length[row_index] = ( 261 | ContiguousStreak( 262 | streak_row_start=contiguous_streak_row_start, 263 | streak_length=contiguous_streak_length, 264 | ) 265 | ) 266 | contiguous_streak_length = 0 267 | 268 | lineno = hunk.source_start + i 269 | this_line_padding = source_lineno_to_padding.get(lineno, 0) 270 | accumulated_source_padding += this_line_padding 271 | row_number = i + accumulated_source_padding 272 | source_lines_by_row_index[row_number] = line 273 | 274 | # TODO: Factor out this code into a function, we're doing the same thing 275 | # for all lines in both source and target hunks. 276 | target_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {} 277 | 278 | accumulated_target_padding = 0 279 | 280 | target_streak_row_start = 0 281 | target_streak_length = 0 282 | for i, line in enumerate(hunk.target_lines()): 283 | if line.is_added: 284 | if target_streak_length == 0: 285 | target_streak_row_start = i 286 | target_streak_length += 1 287 | else: 288 | for row_index in range( 289 | target_streak_row_start, 290 | target_streak_row_start + target_streak_length, 291 | ): 292 | target_row_to_contiguous_streak_length[row_index] = ( 293 | ContiguousStreak( 294 | streak_row_start=target_streak_row_start, 295 | streak_length=target_streak_length, 296 | ) 297 | ) 298 | target_streak_length = 0 299 | 300 | lineno = hunk.target_start + i 301 | this_line_padding = target_lineno_to_padding.get(lineno, 0) 302 | accumulated_target_padding += this_line_padding 303 | row_number = i + accumulated_target_padding 304 | target_lines_by_row_index[row_number] = line 305 | 306 | row_number_to_deletion_ranges = defaultdict(list) 307 | row_number_to_insertion_ranges = defaultdict(list) 308 | 309 | # Collect intraline diff info for highlighting 310 | for row_number, source_line in source_lines_by_row_index.items(): 311 | source_streak = source_row_to_contiguous_streak_length.get(row_number) 312 | target_streak = target_row_to_contiguous_streak_length.get(row_number) 313 | 314 | # TODO: We need to work out the offsets to ensure that we look up 315 | # the correct target and source row streaks to compare. Will probably 316 | # need to append accumulated padding to row numbers 317 | 318 | # print(padded_source_row, padded_target_row, source_line.value) 319 | # if source_streak: 320 | # print(f"sourcestreak {row_number}", source_streak) 321 | # if target_streak: 322 | # print(f"targetstreak {row_number}", target_streak) 323 | 324 | intraline_enabled = ( 325 | source_streak is not None 326 | and target_streak is not None 327 | and source_streak.streak_length == target_streak.streak_length 328 | ) 329 | if not intraline_enabled: 330 | # print(f"skipping row {row_number}") 331 | continue 332 | 333 | target_line = target_lines_by_row_index.get(row_number) 334 | 335 | are_diffable = ( 336 | source_line 337 | and target_line 338 | and source_line.is_removed 339 | and target_line.is_added 340 | ) 341 | if target_line and are_diffable: 342 | matcher = SequenceMatcher( 343 | None, source_line.value, target_line.value 344 | ) 345 | opcodes = matcher.get_opcodes() 346 | ratio = matcher.ratio() 347 | if ratio > 0.5: 348 | for tag, i1, i2, j1, j2 in opcodes: 349 | if tag == "delete": 350 | row_number_to_deletion_ranges[row_number].append( 351 | (i1, i2) 352 | ) 353 | elif tag == "insert": 354 | row_number_to_insertion_ranges[row_number].append( 355 | (j1, j2) 356 | ) 357 | elif tag == "replace": 358 | row_number_to_deletion_ranges[row_number].append( 359 | (i1, i2) 360 | ) 361 | row_number_to_insertion_ranges[row_number].append( 362 | (j1, j2) 363 | ) 364 | 365 | source_syntax_lines: List[List[Segment]] = console.render_lines( 366 | source_syntax 367 | ) 368 | target_syntax_lines = console.render_lines(target_syntax) 369 | 370 | highlighted_source_lines = highlight_and_align_lines_in_hunk( 371 | hunk.source_start, 372 | source_removed_linenos, 373 | source_syntax_lines, 374 | ColorTriplet(255, 0, 0), 375 | source_lineno_to_padding, 376 | dict(row_number_to_deletion_ranges), 377 | gutter_size=len(str(source_lineno_max)) + 2, 378 | ) 379 | highlighted_target_lines = highlight_and_align_lines_in_hunk( 380 | hunk.target_start, 381 | target_added_linenos, 382 | target_syntax_lines, 383 | ColorTriplet(0, 255, 0), 384 | target_lineno_to_padding, 385 | dict(row_number_to_insertion_ranges), 386 | gutter_size=len(str(len(target_lines) + 1)) + 2, 387 | ) 388 | 389 | table = Table.grid() 390 | table.add_column(style="on #0d0f0b") 391 | table.add_column(style="on #0d0f0b") 392 | table.add_row( 393 | SegmentLines(highlighted_source_lines, new_lines=True), 394 | SegmentLines(highlighted_target_lines, new_lines=True), 395 | ) 396 | 397 | hunk_header_style = f"{MONOKAI_BACKGROUND.triplet.hex} on #0d0f0b" 398 | hunk_header = ( 399 | f"[on #0d0f0b dim]@@ [red]-{hunk.source_start},{hunk.source_length}[/] " 400 | f"[green]+{hunk.target_start},{hunk.target_length}[/] " 401 | f"[dim]@@ {hunk.section_header or ''}[/]" 402 | ) 403 | console.rule(hunk_header, characters="╲", style=hunk_header_style) 404 | console.print(table) 405 | 406 | # TODO: File name indicator at bottom of file, if diff is larger than terminal height. 407 | console.rule(style="border", characters="▔") 408 | 409 | console.print( 410 | Align.right( 411 | f"[blue]/[/][red]/[/][green]/[/] [dim]dunk {dunk.__version__}[/] " 412 | ) 413 | ) 414 | # console.save_svg("dunk.svg", title="Diff output generated using Dunk") 415 | 416 | 417 | def highlight_and_align_lines_in_hunk( 418 | start_lineno: int, 419 | highlight_linenos: Set[Optional[int]], 420 | syntax_hunk_lines: List[List[Segment]], 421 | blend_colour: ColorTriplet, 422 | lines_to_pad_above: Dict[int, int], 423 | highlight_ranges: Dict[int, Tuple[int, int]], 424 | gutter_size: int, 425 | ): 426 | highlighted_lines = [] 427 | 428 | # Apply diff-related highlighting to lines 429 | for index, line in enumerate(syntax_hunk_lines): 430 | lineno = index + start_lineno 431 | 432 | if lineno in highlight_linenos: 433 | new_line = [] 434 | segment_number = 0 435 | for segment in line: 436 | style: Style 437 | text, style, control = segment 438 | 439 | if style: 440 | if style.bgcolor: 441 | bgcolor_triplet = style.bgcolor.triplet 442 | cross_fade = 0.85 443 | new_bgcolour_triplet = blend_rgb_cached( 444 | blend_colour, bgcolor_triplet, cross_fade=cross_fade 445 | ) 446 | new_bgcolor = Color.from_triplet(new_bgcolour_triplet) 447 | else: 448 | new_bgcolor = None 449 | 450 | if style.color and segment_number == 1: 451 | new_triplet = blend_rgb_cached( 452 | blend_rgb_cached( 453 | blend_colour, style.color.triplet, cross_fade=0.5 454 | ), 455 | ColorTriplet(255, 255, 255), 456 | cross_fade=0.4, 457 | ) 458 | new_color = Color.from_triplet(new_triplet) 459 | else: 460 | new_color = None 461 | 462 | overlay_style = Style.from_color( 463 | color=new_color, bgcolor=new_bgcolor 464 | ) 465 | updated_style = style + overlay_style 466 | new_line.append(Segment(text, updated_style, control)) 467 | else: 468 | new_line.append(segment) 469 | segment_number += 1 470 | else: 471 | new_line = line[:] 472 | 473 | # Pad above the line if required 474 | pad = lines_to_pad_above.get(lineno, 0) 475 | for i in range(pad): 476 | highlighted_lines.append( 477 | [ 478 | Segment( 479 | "╲" * console.width, Style.from_color(color=MONOKAI_BACKGROUND) 480 | ) 481 | ] 482 | ) 483 | 484 | # Finally, apply the intraline diff highlighting for this line if possible 485 | if index in highlight_ranges: 486 | line_as_text = Text.assemble( 487 | *((text, style) for text, style, control in new_line), end="" 488 | ) 489 | intraline_bgcolor = Color.from_triplet( 490 | blend_rgb_cached( 491 | blend_colour, MONOKAI_BACKGROUND.triplet, cross_fade=0.6 492 | ) 493 | ) 494 | intraline_color = Color.from_triplet( 495 | blend_rgb_cached( 496 | intraline_bgcolor.triplet, 497 | Color.from_rgb(255, 255, 255).triplet, 498 | cross_fade=0.8, 499 | ) 500 | ) 501 | for start, end in highlight_ranges.get(index): 502 | line_as_text.stylize( 503 | Style.from_color(color=intraline_color, bgcolor=intraline_bgcolor), 504 | start=start + gutter_size + 1, 505 | end=end + gutter_size + 1, 506 | ) 507 | new_line = list(console.render(line_as_text)) 508 | highlighted_lines.append(new_line) 509 | return highlighted_lines 510 | 511 | 512 | @functools.lru_cache(maxsize=128) 513 | def blend_rgb_cached( 514 | colour1: ColorTriplet, colour2: ColorTriplet, cross_fade: float = 0.6 515 | ) -> ColorTriplet: 516 | return blend_rgb(colour1, colour2, cross_fade=cross_fade) 517 | 518 | 519 | if __name__ == "__main__": 520 | main() 521 | -------------------------------------------------------------------------------- /dunk/renderables.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | 4 | from rich.align import Align 5 | from rich.console import Console 6 | from rich.console import ConsoleOptions, RenderResult 7 | from rich.markup import escape 8 | from rich.rule import Rule 9 | from rich.segment import Segment 10 | from rich.table import Table 11 | from rich.text import Text 12 | from unidiff import PatchedFile 13 | 14 | from dunk.underline_bar import UnderlineBar 15 | 16 | 17 | def simple_pluralise(word: str, number: int) -> str: 18 | if number == 1: 19 | return word 20 | else: 21 | return word + "s" 22 | 23 | 24 | @dataclass 25 | class PatchSetHeader: 26 | file_modifications: int 27 | file_additions: int 28 | file_removals: int 29 | line_additions: int 30 | line_removals: int 31 | 32 | def __rich_console__( 33 | self, console: Console, options: ConsoleOptions 34 | ) -> RenderResult: 35 | if self.file_modifications: 36 | yield Align.center( 37 | f"[blue]{self.file_modifications} {simple_pluralise('file', self.file_modifications)} changed" 38 | ) 39 | if self.file_additions: 40 | yield Align.center( 41 | f"[green]{self.file_additions} {simple_pluralise('file', self.file_additions)} added" 42 | ) 43 | if self.file_removals: 44 | yield Align.center( 45 | f"[red]{self.file_removals} {simple_pluralise('file', self.file_removals)} removed" 46 | ) 47 | 48 | bar_width = console.width // 5 49 | changed_lines = max(1, self.line_additions + self.line_removals) 50 | added_lines_ratio = self.line_additions / changed_lines 51 | 52 | line_changes_summary = Table.grid() 53 | line_changes_summary.add_column() 54 | line_changes_summary.add_column() 55 | line_changes_summary.add_column() 56 | line_changes_summary.add_row( 57 | f"[bold green]+{self.line_additions} ", 58 | UnderlineBar( 59 | highlight_range=(0, added_lines_ratio * bar_width), 60 | highlight_style="green", 61 | background_style="red", 62 | width=bar_width, 63 | ), 64 | f" [bold red]-{self.line_removals}", 65 | ) 66 | 67 | bar_hpad = len(str(self.line_additions)) + len(str(self.line_removals)) + 4 68 | yield Align.center(line_changes_summary, width=bar_width + bar_hpad) 69 | yield Segment.line() 70 | 71 | 72 | class RemovedFileBody: 73 | def __rich_console__( 74 | self, console: Console, options: ConsoleOptions 75 | ) -> RenderResult: 76 | yield Rule(characters="╲", style="hatched") 77 | yield Rule(" [red]File was removed ", characters="╲", style="hatched") 78 | yield Rule(characters="╲", style="hatched") 79 | yield Rule(style="border", characters="▔") 80 | 81 | 82 | @dataclass 83 | class BinaryFileBody: 84 | size_in_bytes: int 85 | 86 | def __rich_console__( 87 | self, console: Console, options: ConsoleOptions 88 | ) -> RenderResult: 89 | yield Rule(characters="╲", style="hatched") 90 | yield Rule( 91 | Text(f" File is binary · {self.size_in_bytes} bytes ", style="blue"), 92 | characters="╲", 93 | style="hatched", 94 | ) 95 | yield Rule(characters="╲", style="hatched") 96 | yield Rule(style="border", characters="▔") 97 | 98 | 99 | class PatchedFileHeader: 100 | def __init__(self, patch: PatchedFile): 101 | self.patch = patch 102 | if patch.is_rename: 103 | self.path_prefix = ( 104 | f"[dim][s]{escape(Path(patch.source_file).name)}[/] → [/]" 105 | ) 106 | elif patch.is_added_file: 107 | self.path_prefix = f"[bold green]Added [/]" 108 | else: 109 | self.path_prefix = "" 110 | 111 | def __rich_console__( 112 | self, console: Console, options: ConsoleOptions 113 | ) -> RenderResult: 114 | yield Rule( 115 | f"{self.path_prefix}[b]{escape(self.patch.path)}[/] ([green]{self.patch.added} additions[/], " 116 | f"[red]{self.patch.removed} removals[/])", 117 | style="border", 118 | characters="▁", 119 | ) 120 | 121 | 122 | class OnlyRenamedFileBody: 123 | """Represents a file that was renamed but the content was not changed.""" 124 | 125 | def __init__(self, patch: PatchedFile): 126 | self.patch = patch 127 | 128 | def __rich_console__( 129 | self, console: Console, options: ConsoleOptions 130 | ) -> RenderResult: 131 | yield Rule(characters="╲", style="hatched") 132 | yield Rule(" [blue]File was only renamed ", characters="╲", style="hatched") 133 | yield Rule(characters="╲", style="hatched") 134 | yield Rule(style="border", characters="▔") 135 | -------------------------------------------------------------------------------- /dunk/underline_bar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.console import ConsoleOptions, Console, RenderResult 4 | from rich.style import StyleType 5 | from rich.text import Text 6 | 7 | 8 | class UnderlineBar: 9 | """Thin horizontal bar with a portion highlighted. 10 | 11 | Args: 12 | highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight) 13 | highlight_style (StyleType): The style of the highlighted range of the bar. 14 | background_style (StyleType): The style of the non-highlighted range(s) of the bar. 15 | width (int, optional): The width of the bar, or ``None`` to fill available width. 16 | """ 17 | 18 | def __init__( 19 | self, 20 | highlight_range: tuple[float, float] = (0, 0), 21 | highlight_style: StyleType = "magenta", 22 | background_style: StyleType = "grey37", 23 | clickable_ranges: dict[str, tuple[int, int]] | None = None, 24 | width: int | None = None, 25 | ) -> None: 26 | self.highlight_range = highlight_range 27 | self.highlight_style = highlight_style 28 | self.background_style = background_style 29 | self.clickable_ranges = clickable_ranges or {} 30 | self.width = width 31 | 32 | def __rich_console__( 33 | self, console: Console, options: ConsoleOptions 34 | ) -> RenderResult: 35 | highlight_style = console.get_style(self.highlight_style) 36 | background_style = console.get_style(self.background_style) 37 | 38 | half_bar_right = "╸" 39 | half_bar_left = "╺" 40 | bar = "━" 41 | 42 | width = self.width or options.max_width 43 | start, end = self.highlight_range 44 | 45 | start = max(start, 0) 46 | end = min(end, width) 47 | 48 | output_bar = Text("", end="") 49 | 50 | if start == end == 0 or end < 0 or start > end: 51 | output_bar.append(Text(bar * width, style=background_style, end="")) 52 | yield output_bar 53 | return 54 | 55 | # Round start and end to nearest half 56 | start = round(start * 2) / 2 57 | end = round(end * 2) / 2 58 | 59 | # Check if we start/end on a number that rounds to a .5 60 | half_start = start - int(start) > 0 61 | half_end = end - int(end) > 0 62 | 63 | # Initial non-highlighted portion of bar 64 | output_bar.append( 65 | Text(bar * (int(start - 0.5)), style=background_style, end="") 66 | ) 67 | if not half_start and start > 0: 68 | output_bar.append(Text(half_bar_right, style=background_style, end="")) 69 | 70 | # The highlighted portion 71 | bar_width = int(end) - int(start) 72 | if half_start: 73 | output_bar.append( 74 | Text( 75 | half_bar_left + bar * (bar_width - 1), style=highlight_style, end="" 76 | ) 77 | ) 78 | else: 79 | output_bar.append(Text(bar * bar_width, style=highlight_style, end="")) 80 | if half_end: 81 | output_bar.append(Text(half_bar_right, style=highlight_style, end="")) 82 | 83 | # The non-highlighted tail 84 | if not half_end and end - width != 0: 85 | output_bar.append(Text(half_bar_left, style=background_style, end="")) 86 | output_bar.append( 87 | Text(bar * (int(width) - int(end) - 1), style=background_style, end="") 88 | ) 89 | 90 | # Fire actions when certain ranges are clicked (e.g. for tabs) 91 | for range_name, (start, end) in self.clickable_ranges.items(): 92 | output_bar.apply_meta( 93 | {"@click": f"range_clicked('{range_name}')"}, start, end 94 | ) 95 | 96 | yield output_bar 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dunk" 3 | version = "0.5.0b0" 4 | description = "Beautiful side-by-side diffs in your terminal" 5 | authors = [ 6 | {name = "Darren Burns", email = "darrenb900@gmail.com"} 7 | ] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | keywords = ["diff", "terminal", "side-by-side", "git", "cli", "dev-tools"] 11 | classifiers = [ 12 | "Environment :: Console", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Programming Language :: Python :: 3.14", 20 | ] 21 | 22 | requires-python = ">=3.11" 23 | dependencies = [ 24 | "unidiff>=0.7", 25 | "rich>=12.1.0" 26 | ] 27 | 28 | [project.scripts] 29 | dunk = "dunk.dunk:main" 30 | 31 | [project.urls] 32 | homepage = "https://github.com/darrenburns/dunk" 33 | repository = "https://github.com/darrenburns/dunk" 34 | issues = "https://github.com/darrenburns/dunk/issues" 35 | documentation = "https://github.com/darrenburns/dunk/blob/main/README.md" 36 | 37 | [tool.uv] 38 | dev-dependencies = [ 39 | "pytest", 40 | ] 41 | 42 | [tool.hatch.metadata] 43 | allow-direct-references = true 44 | 45 | [tool.hatch.build.targets.wheel] 46 | packages = ["dunk"] 47 | 48 | [build-system] 49 | requires = ["hatchling"] 50 | build-backend = "hatchling.build" -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.11" 4 | 5 | [[package]] 6 | name = "colorama" 7 | version = "0.4.6" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 12 | ] 13 | 14 | [[package]] 15 | name = "dunk" 16 | version = "0.5.0b0" 17 | source = { editable = "." } 18 | dependencies = [ 19 | { name = "rich" }, 20 | { name = "unidiff" }, 21 | ] 22 | 23 | [package.dev-dependencies] 24 | dev = [ 25 | { name = "pytest" }, 26 | ] 27 | 28 | [package.metadata] 29 | requires-dist = [ 30 | { name = "rich", specifier = ">=12.1.0" }, 31 | { name = "unidiff", specifier = ">=0.7" }, 32 | ] 33 | 34 | [package.metadata.requires-dev] 35 | dev = [{ name = "pytest" }] 36 | 37 | [[package]] 38 | name = "iniconfig" 39 | version = "2.1.0" 40 | source = { registry = "https://pypi.org/simple" } 41 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } 42 | wheels = [ 43 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, 44 | ] 45 | 46 | [[package]] 47 | name = "markdown-it-py" 48 | version = "3.0.0" 49 | source = { registry = "https://pypi.org/simple" } 50 | dependencies = [ 51 | { name = "mdurl" }, 52 | ] 53 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 56 | ] 57 | 58 | [[package]] 59 | name = "mdurl" 60 | version = "0.1.2" 61 | source = { registry = "https://pypi.org/simple" } 62 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 63 | wheels = [ 64 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 65 | ] 66 | 67 | [[package]] 68 | name = "packaging" 69 | version = "24.2" 70 | source = { registry = "https://pypi.org/simple" } 71 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 72 | wheels = [ 73 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 74 | ] 75 | 76 | [[package]] 77 | name = "pluggy" 78 | version = "1.5.0" 79 | source = { registry = "https://pypi.org/simple" } 80 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 81 | wheels = [ 82 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 83 | ] 84 | 85 | [[package]] 86 | name = "pygments" 87 | version = "2.19.1" 88 | source = { registry = "https://pypi.org/simple" } 89 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 92 | ] 93 | 94 | [[package]] 95 | name = "pytest" 96 | version = "8.3.5" 97 | source = { registry = "https://pypi.org/simple" } 98 | dependencies = [ 99 | { name = "colorama", marker = "sys_platform == 'win32'" }, 100 | { name = "iniconfig" }, 101 | { name = "packaging" }, 102 | { name = "pluggy" }, 103 | ] 104 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } 105 | wheels = [ 106 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, 107 | ] 108 | 109 | [[package]] 110 | name = "rich" 111 | version = "14.0.0" 112 | source = { registry = "https://pypi.org/simple" } 113 | dependencies = [ 114 | { name = "markdown-it-py" }, 115 | { name = "pygments" }, 116 | ] 117 | sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } 118 | wheels = [ 119 | { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, 120 | ] 121 | 122 | [[package]] 123 | name = "unidiff" 124 | version = "0.7.5" 125 | source = { registry = "https://pypi.org/simple" } 126 | sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931 } 127 | wheels = [ 128 | { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386 }, 129 | ] 130 | --------------------------------------------------------------------------------