diff --git a/kittens/diff/main.py b/kittens/diff/main.py index cc1a1c4ab..808e59644 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -152,6 +152,12 @@ opt('dark_highlight_added_bg', '#31503d', option_type='to_color') opt('added_margin_bg', '#cdffd8', option_type='to_color') opt('dark_added_margin_bg', '#31503d', option_type='to_color') +opt('moved_bg', '#fffde7', option_type='to_color', long_text='Moved text backgrounds (same text that was removed in one place and added in another)') +opt('dark_moved_bg', '#2c2200', option_type='to_color') + +opt('moved_margin_bg', '#fff3b0', option_type='to_color') +opt('dark_moved_margin_bg', '#4a3800', option_type='to_color') + opt('filler_bg', '#fafbfc', option_type='to_color', long_text='Filler (empty) line background') opt('dark_filler_bg', '#262c36', option_type='to_color') diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index 81ff508bf..b1779a52e 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -14,6 +14,7 @@ import ( "sync" parallel "github.com/kovidgoyal/go-parallel" + "github.com/kovidgoyal/kitty/tools/simdstring" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" "github.com/kovidgoyal/kitty/tools/utils/shlex" @@ -336,6 +337,7 @@ func (self *Hunk) finalize(left_lines, right_lines []string) error { type Patch struct { all_hunks []*Hunk largest_line_number, added_count, removed_count int + left_moved_lines, right_moved_lines *utils.Set[int] } func (self *Patch) Len() int { return len(self.all_hunks) } @@ -451,6 +453,62 @@ func (self *Patch) compute_centers(left_lines, right_lines []string) error { return nil } +func (self *Patch) detect_moved_lines(left_lines, right_lines []string) { + // Build maps from line text to lists of line numbers for removed and added lines. + removed := make(map[string][]int) // text -> left line numbers + added := make(map[string][]int) // text -> right line numbers + // Use SIMD to efficiently find non-blank lines: a line is non-blank if it + // contains at least one character that is not a space or tab. + is_non_blank := func(text string) bool { + for len(text) > 0 { + idx := simdstring.IndexByte2String(text, ' ', '\t') + if idx != 0 { + // idx < 0: no space/tab found, remaining chars are non-blank; + // idx > 0: non-blank chars exist before the first space/tab. + return true + } + text = text[1:] + } + return false + } + for _, hunk := range self.all_hunks { + for _, chunk := range hunk.chunks { + if !chunk.is_context { + for i := 0; i < chunk.left_count; i++ { + lnum := chunk.left_start + i + text := left_lines[lnum] + if is_non_blank(text) { + removed[text] = append(removed[text], lnum) + } + } + for i := 0; i < chunk.right_count; i++ { + rnum := chunk.right_start + i + text := right_lines[rnum] + if is_non_blank(text) { + added[text] = append(added[text], rnum) + } + } + } + } + } + // Lines that appear in both removed and added sets are moved lines. When a + // line appears multiple times on each side, only min(left_count, + // right_count) occurrences are marked as moved. + self.left_moved_lines = utils.NewSet[int]() + self.right_moved_lines = utils.NewSet[int]() + for text, lnums := range removed { + if rnums, ok := added[text]; ok { + count := min(len(lnums), len(rnums)) + for _, lnum := range lnums[:count] { + self.left_moved_lines.Add(lnum) + } + for _, rnum := range rnums[:count] { + self.right_moved_lines.Add(rnum) + } + } + } +} + func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) { ans = &Patch{all_hunks: make([]*Hunk, 0, 32)} var current_hunk *Hunk @@ -486,6 +544,9 @@ func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number } err = ans.compute_centers(left_lines, right_lines) + if err == nil { + ans.detect_moved_lines(left_lines, right_lines) + } return } diff --git a/kittens/diff/render.go b/kittens/diff/render.go index 512445b58..e8db88c0a 100644 --- a/kittens/diff/render.go +++ b/kittens/diff/render.go @@ -40,6 +40,7 @@ type HalfScreenLine struct { marked_up_margin_text string marked_up_text string is_filler bool + is_moved bool cached_wcswidth int } @@ -81,6 +82,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.left.is_filler { left_margin = format_as_sgr.margin_filler + left_margin left_text = format_as_sgr.filler + left_text + } else if sl.left.is_moved { + left_margin = format_as_sgr.moved_margin + left_margin + left_text = format_as_sgr.moved + left_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -104,6 +108,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.right.is_filler { right_margin = format_as_sgr.margin_filler + right_margin right_text = format_as_sgr.filler + right_text + } else if sl.right.is_moved { + right_margin = format_as_sgr.moved_margin + right_margin + right_text = format_as_sgr.moved + right_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -156,7 +163,7 @@ func place_in(text string, sz int) string { } var format_as_sgr struct { - title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search string + title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search, moved, moved_margin string } var statusline_format, added_count_format, removed_count_format, message_format func(...any) string @@ -175,6 +182,8 @@ type ResolvedColors struct { Margin_bg style.RGBA Margin_fg style.RGBA Margin_filler_bg style.NullableColor + Moved_bg style.RGBA + Moved_margin_bg style.RGBA Removed_bg style.RGBA Removed_margin_bg style.RGBA Search_bg style.RGBA @@ -202,6 +211,8 @@ func create_formatters() { rc.Margin_bg = conf.Dark_margin_bg rc.Margin_fg = conf.Dark_margin_fg rc.Margin_filler_bg = conf.Dark_margin_filler_bg + rc.Moved_bg = conf.Dark_moved_bg + rc.Moved_margin_bg = conf.Dark_moved_margin_bg rc.Removed_bg = conf.Dark_removed_bg rc.Removed_margin_bg = conf.Dark_removed_margin_bg rc.Search_bg = conf.Dark_search_bg @@ -223,6 +234,8 @@ func create_formatters() { rc.Margin_bg = conf.Margin_bg rc.Margin_fg = conf.Margin_fg rc.Margin_filler_bg = conf.Margin_filler_bg + rc.Moved_bg = conf.Moved_bg + rc.Moved_margin_bg = conf.Moved_margin_bg rc.Removed_bg = conf.Removed_bg rc.Removed_margin_bg = conf.Removed_margin_bg rc.Search_bg = conf.Search_bg @@ -248,6 +261,8 @@ func create_formatters() { format_as_sgr.added_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Added_margin_bg.AsRGBSharp())) format_as_sgr.removed = only_open("bg=" + rc.Removed_bg.AsRGBSharp()) format_as_sgr.removed_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Removed_margin_bg.AsRGBSharp())) + format_as_sgr.moved = only_open("bg=" + rc.Moved_bg.AsRGBSharp()) + format_as_sgr.moved_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Moved_margin_bg.AsRGBSharp())) format_as_sgr.title = only_open(fmt.Sprintf("fg=%s bg=%s bold", rc.Title_fg.AsRGBSharp(), rc.Title_bg.AsRGBSharp())) format_as_sgr.margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Margin_bg.AsRGBSharp())) format_as_sgr.hunk = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Hunk_bg.AsRGBSharp())) @@ -532,7 +547,9 @@ type DiffData struct { left_path, right_path string available_cols, margin_size int - left_lines, right_lines []string + left_lines, right_lines []string + left_moved_lines *utils.Set[int] + right_moved_lines *utils.Set[int] } func hunk_title(hunk *Hunk) string { @@ -567,7 +584,7 @@ func splitlines(text string, width int) []string { return style.WrapTextAsLines(text, width, style.WrapOptions{}) } -func render_half_line(line_number int, line, ltype string, available_cols int, center Center, ans []HalfScreenLine) []HalfScreenLine { +func render_half_line(line_number int, line, ltype string, available_cols int, center Center, is_moved bool, ans []HalfScreenLine) []HalfScreenLine { var regions []Region if ltype == "remove" { regions = center.left_regions @@ -583,7 +600,7 @@ func render_half_line(line_number int, line, ltype string, available_cols int, c } lnum := strconv.Itoa(line_number + 1) for _, sc := range splitlines(line, available_cols) { - ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc}) + ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc, is_moved: is_moved}) lnum = "" } return ans @@ -601,13 +618,15 @@ func lines_for_diff_chunk(data *DiffData, _ int, chunk *Chunk, _ int, ans []*Log } if i < chunk.left_count { left_lnum = chunk.left_start + i - ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, ll) + left_is_moved := data.left_moved_lines != nil && data.left_moved_lines.Has(left_lnum) + ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, left_is_moved, ll) left_lnum++ } if i < chunk.right_count { right_lnum = chunk.right_start + i - rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, rl) + right_is_moved := data.right_moved_lines != nil && data.right_moved_lines.Has(right_lnum) + rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, right_is_moved, rl) right_lnum++ } @@ -663,7 +682,10 @@ func lines_for_diff(left_path string, right_path string, patch *Patch, columns, return append(ans, &ht), nil } available_cols := columns/2 - margin_size - data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size} + data := DiffData{ + left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size, + left_moved_lines: patch.left_moved_lines, right_moved_lines: patch.right_moved_lines, + } if left_path != "" { data.left_lines, err = highlighted_lines_for_path(left_path) if err != nil { @@ -720,7 +742,7 @@ func all_lines(path string, columns, margin_size int, is_add bool, ans []*Logica } for line_number, line := range lines { hlines := make([]HalfScreenLine, 0, 8) - hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, hlines) + hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, false, hlines) l := ll if is_add { l.right_reference.linenum = line_number + 1