From 16490942d662d3797c3eaec3db2065b6278bb69b Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 13:03:35 +0200 Subject: [PATCH] fix: coalesce fragmented word-diff highlights --- TODO.md | 3 +- docs/editing.md | 2 +- src/diff.rs | 135 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index b42471f..0104783 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,8 @@ - it should be taller - [x] add support for zig syntax highlighting - [x] add the `*` action from vim on word to highlights and searches in buffer in one action. -- [ ] improve diff view: we should be able to tell what the part of the code we're looking at (any given change hunk) and where it belongs. +- [x] diff view: improve word-diff highlighting +- [ ] diff view: we should be able to tell what the part of the code we're looking at (any given change hunk) and where it belongs. - i think it would be nice if we in the diff view somehow displayed the function name and/or class name that a hunk belongs to - we should consider the ui before implementing this - [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc. diff --git a/docs/editing.md b/docs/editing.md index 743c9ea..4cece99 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -452,7 +452,7 @@ In split diffs, changed text rows use muted red backgrounds in Before and muted The row tint also covers the line-number gutter on wrapped continuations and blank wrap-padding rows on the shorter side of an aligned pair. Empty source lines keep the same full-row tint; missing source counterparts retain their normal-background hatching. -Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting. +Character-wise highlighting is enabled automatically within paired changed lines, with a stronger red/green background over changed grapheme spans. To reduce fragmented highlighting, changes separated by at most two unchanged graphemes are merged; short unchanged islands between large edits are also absorbed using VS Code-style size heuristics. Larger common regions retain the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting. Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes. diff --git a/src/diff.rs b/src/diff.rs index 6b90352..dc515e6 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -2241,13 +2241,83 @@ fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range; } let before: Vec<_> = before.graphemes(true).collect(); let after: Vec<_> = after.graphemes(true).collect(); - similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) - .into_iter() - .filter_map(|op| { - let (tag, before, after) = op.as_tag_tuple(); - (tag != similar::DiffTag::Equal).then_some([before, after]) - }) - .collect() + let mut changes: Vec<[std::ops::Range; 2]> = Vec::new(); + let (mut before_offset, mut after_offset) = (0, 0); + for op in similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) { + let (tag, before, after) = op.as_tag_tuple(); + // similar's optimized empty-range anchors can be stale; consume lengths in order. + let before = before_offset..before_offset + before.len(); + let after = after_offset..after_offset + after.len(); + (before_offset, after_offset) = (before.end, after.end); + if tag == similar::DiffTag::Equal { + continue; + } + if let Some(previous) = changes.last_mut() + && (before.start - previous[0].end <= 2 || after.start - previous[1].end <= 2) + { + previous[0].end = before.end; + previous[1].end = after.end; + } else { + changes.push([before, after]); + } + } + + // A merge can enable an earlier gap; VS Code bounds this pass to 11 sweeps. + for _ in 0..11 { + let mut kept = 1; + for index in 1..changes.len() { + if short_match_between_large_changes(&before, &changes[kept - 1], &changes[index]) { + changes[kept - 1][0].end = changes[index][0].end; + changes[kept - 1][1].end = changes[index][1].end; + } else { + if kept != index { + changes[kept] = changes[index].clone(); + } + kept += 1; + } + } + if kept >= changes.len() { + break; + } + changes.truncate(kept); + } + changes +} + +fn short_match_between_large_changes( + before: &[&str], + previous: &[std::ops::Range; 2], + next: &[std::ops::Range; 2], +) -> bool { + let gap = &before[previous[0].end..next[0].start]; + if gap.len() > 500 { + return false; + } + let start = gap + .iter() + .position(|grapheme| !grapheme.trim().is_empty()) + .unwrap_or(gap.len()); + let trimmed_len = gap[start..] + .iter() + .rposition(|grapheme| !grapheme.trim().is_empty()) + .map_or(0, |end| end + 1); + if trimmed_len > 20 { + return false; + } + + // VS Code's size heuristic, specialized to single lines and normalized by 130^2.25: + // https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts + let score = |change: &[std::ops::Range; 2]| { + let weight = change + .iter() + .map(|range| { + let length = range.len().min(130) as f64 / 130.0; + length * length.sqrt() + }) + .sum::(); + weight * weight.sqrt() + }; + score(previous) + score(next) > 1.3 } fn line_width(line: &str) -> usize { @@ -4522,6 +4592,57 @@ mod tests { assert!(state.rows[0].changes.is_empty()); } + #[test] + fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() { + let mut cases = vec![ + ("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]), + ("base".into(), "edited".into(), vec![[0..4, 0..6]]), + ( + "a...c".into(), + "b...d".into(), + vec![[0..1, 0..1], [4..5, 4..5]], + ), + ( + "a界e\u{301}c".into(), + "b界e\u{301}d".into(), + vec![[0..4, 0..4]], + ), + ]; + for (size, gap, merge) in [ + (60, " = ".to_owned(), false), + (70, " = ".to_owned(), true), + (70, "x".repeat(20), true), + (70, "unchanged_expression".to_owned(), true), + (70, "unchanged_expression_".to_owned(), false), + (70, " ".repeat(500), true), + (70, " ".repeat(501), false), + ] { + let end = 2 * size + gap.len(); + let second = size + gap.len(); + cases.push(( + format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)), + format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)), + if merge { + vec![[0..end, 0..end]] + } else { + vec![[0..size, 0..size], [second..end, second..end]] + }, + )); + } + cases.push(( + format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)), + format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)), + vec![[0..149, 0..149]], + )); + for (before, after, expected) in cases { + let mut state = live_state(vec![before], vec![after.clone()], false); + assert_eq!(state.rows[0].changes, expected, "{after:?}"); + state.refresh_after(&[format!("{after}!")], 1); + state.refresh_after(std::slice::from_ref(&after), 2); + assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}"); + } + } + #[test] fn reverting_loaded_file_keeps_unloaded_changes_selectable() { let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);