Adding upstream version 0.5.0~b0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
e1bbced648
commit
c8516ea709
9 changed files with 988 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
.idea
|
||||
dunk/__pycache__
|
||||
dist
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -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.
|
36
README.md
Normal file
36
README.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
# dunk
|
||||
|
||||
Pipe your `git diff` output into `dunk` to make it prettier!
|
||||
|
||||

|
||||
|
||||
> ⚠️ 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
|
||||
```
|
1
dunk/__init__.py
Normal file
1
dunk/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "0.5.0b0"
|
520
dunk/dunk.py
Normal file
520
dunk/dunk.py
Normal file
|
@ -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()
|
134
dunk/renderables.py
Normal file
134
dunk/renderables.py
Normal file
|
@ -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="▔")
|
96
dunk/underline_bar.py
Normal file
96
dunk/underline_bar.py
Normal file
|
@ -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
|
50
pyproject.toml
Normal file
50
pyproject.toml
Normal file
|
@ -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"
|
129
uv.lock
generated
Normal file
129
uv.lock
generated
Normal file
|
@ -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 },
|
||||
]
|
Loading…
Add table
Add a link
Reference in a new issue