diff --git a/helix-core/src/conflict.rs b/helix-core/src/conflict.rs index 93164c15..7f65b5e7 100644 --- a/helix-core/src/conflict.rs +++ b/helix-core/src/conflict.rs @@ -415,6 +415,31 @@ fn git_sep_line_end(region: &ConflictRegion, text: &Rope) -> Option { } } +/// Returns the document line numbers of every conflict marker line in `text`: +/// the opening `<<<<<<<`, every section marker (`|||||||`, `=======`, `+++++++`, +/// `-------`), and the closing `>>>>>>>`. +/// +/// The returned `Vec` is sorted and deduplicated. +pub fn conflict_marker_lines(text: &Rope) -> Vec { + let mut lines: Vec = find_conflicts(text) + .iter() + .flat_map(|region| { + // Section marker lines (includes the opening <<<<<<< via sections[0].marker_start). + let section_lines = region + .sections + .iter() + .map(|s| text.char_to_line(s.marker_start)); + // Closing >>>>>>> line — `end` is exclusive and points past the trailing + // newline, so subtract 1 to land somewhere on the last line. + let end_line = std::iter::once(text.char_to_line(region.end.saturating_sub(1))); + section_lines.chain(end_line) + }) + .collect(); + lines.sort_unstable(); + lines.dedup(); + lines +} + // ── Content helpers ─────────────────────────────────────────────────────────── /// Return the char range of the first `Side` section ("current" change). @@ -1011,6 +1036,23 @@ mod tests { state.insert(c.start, 0); assert_eq!(conflict_refine_pair(&state, c), 0); } + #[test] + fn conflict_marker_lines_returns_sorted() { + let text = concat!( + "before\n", + "<<<<<<< HEAD\nours\n||||||| base\nbase\n=======\ntheirs\n>>>>>>> b\n", + "after\n", + ); + let r = rope(text); + let lines = conflict_marker_lines(&r); + assert!(!lines.is_empty()); + for i in 0..lines.len() - 1 { + assert!(lines[i] < lines[i + 1]); + } + let dedup_check: Vec<_> = lines.iter().collect(); + assert_eq!(dedup_check.len(), lines.len()); + } + #[test] fn all_sides_content_concatenates() { let text = concat!( diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f49a4504..6ec599d0 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -15,7 +15,8 @@ use crate::{ use helix_core::{ conflict::{ - conflict_at, conflict_pair_sections, conflict_refine_pair, find_conflicts, refine_diff, + conflict_at, conflict_marker_lines, conflict_pair_sections, conflict_refine_pair, + find_conflicts, refine_diff, }, diagnostic::NumberOrString, graphemes::{next_grapheme_boundary, prev_grapheme_boundary}, @@ -97,6 +98,15 @@ impl EditorView { let text_annotations = view.text_annotations(doc, Some(theme)); let mut decorations = DecorationManager::default(); + // Conflict section/marker backgrounds — registered first so they have + // the lowest priority and are overwritten by cursorline and DAP highlights. + if let Some(deco) = Self::conflict_section_line_deco(doc, view, theme) { + decorations.add_decoration(deco); + } + if let Some(deco) = Self::conflict_marker_line_deco(doc, view, theme) { + decorations.add_decoration(deco); + } + if is_focused && config.cursorline { decorations.add_decoration(Self::cursorline(doc, view, theme)); } @@ -187,8 +197,6 @@ impl EditorView { ); } - Self::render_rulers(editor, doc, view, inner, surface, theme); - let primary_cursor = doc .selection(view.id) .primary() @@ -223,6 +231,9 @@ impl EditorView { theme, decorations, ); + // Rulers are painted after render_document so they always appear on top + // of decoration backgrounds (conflict sections, cursorline, etc.). + Self::render_rulers(editor, doc, view, inner, surface, theme); // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { @@ -721,7 +732,82 @@ impl EditorView { Some(OverlayHighlights::Heterogenous { highlights: spans }) } - /// Render bufferline at the top + /// Returns a decoration that paints per-section background colors across + /// every line of each conflict section: + /// - `diff.conflict.current` — section index % 3 == 0 (ours / first side) + /// - `diff.conflict.base` — section index % 3 == 1 (common base) + /// - `diff.conflict.incoming` — section index % 3 == 2 (theirs / last side) + /// + /// For jj N-way conflicts the colors cycle through the three slots by section + /// index. Returns `None` if none of the three scopes are defined in the theme. + pub fn conflict_section_line_deco<'a>( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Option { + let styles = [ + theme.try_get("diff.conflict.current"), + theme.try_get("diff.conflict.base"), + theme.try_get("diff.conflict.incoming"), + ]; + if styles.iter().all(|s| s.is_none()) { + return None; + } + let text = doc.text(); + // Collect (line_start, line_end_excl, slot) for every section content range. + let ranges: Vec<(usize, usize, usize)> = find_conflicts(text) + .iter() + .flat_map(|region| { + region.sections.iter().enumerate().map(|(i, s)| { + let l0 = text.char_to_line(s.content_start); + let l1 = text.char_to_line(s.content_end); + let slot = match s.kind { + helix_core::conflict::SectionKind::Base => 1, + helix_core::conflict::SectionKind::Side => { + let side_idx = region.sections[..i] + .iter() + .filter(|s| s.kind == helix_core::conflict::SectionKind::Side) + .count(); + if side_idx % 2 == 0 { + 0 + } else { + 2 + } + } + }; + (l0, l1, slot) + }) + }) + .collect(); + let inner = view.inner_area(doc); + Some(move |renderer: &mut TextRenderer, pos: LinePos| { + for &(l0, l1, slot) in &ranges { + if pos.doc_line >= l0 && pos.doc_line < l1 { + if let Some(style) = styles[slot] { + renderer + .set_style(Rect::new(inner.x, pos.visual_line, inner.width, 1), style); + } + break; + } + } + }) + } + + pub fn conflict_marker_line_deco<'a>( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Option { + let style = theme.try_get("diff.conflict.marker")?; + let lines = conflict_marker_lines(doc.text()); + let inner = view.inner_area(doc); + Some(move |renderer: &mut TextRenderer, pos: LinePos| { + if lines.binary_search(&pos.doc_line).is_ok() { + renderer.set_style(Rect::new(inner.x, pos.visual_line, inner.width, 1), style); + } + }) + } + pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) { let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer surface.clear_with( diff --git a/runtime/themes/base16_default_dark.toml b/runtime/themes/base16_default_dark.toml index 5f39b4aa..3f670e22 100644 --- a/runtime/themes/base16_default_dark.toml +++ b/runtime/themes/base16_default_dark.toml @@ -48,8 +48,12 @@ "diff.plus" = "base0B" "diff.delta" = "base09" "diff.minus" = "base08" -"diff.conflict.removed" = { bg = "#3d2020" } -"diff.conflict.added" = { bg = "#1e3020" } +"diff.conflict.removed" = { bg = "#3d2020" } +"diff.conflict.added" = { bg = "#1e3020" } +"diff.conflict.marker" = { bg = "#202020" } +"diff.conflict.current" = { bg = "#1c1c22" } +"diff.conflict.base" = { bg = "#202020" } +"diff.conflict.incoming" = { bg = "#1c221c" } "diagnostic" = { modifiers = ["underlined"] } "ui.gutter" = { bg = "base01" } diff --git a/runtime/themes/base16_default_light.toml b/runtime/themes/base16_default_light.toml index 73374f11..2f759feb 100644 --- a/runtime/themes/base16_default_light.toml +++ b/runtime/themes/base16_default_light.toml @@ -48,8 +48,12 @@ "diff.plus" = "base0B" "diff.delta" = "base09" "diff.minus" = "base08" -"diff.conflict.removed" = { bg = "#f2d0d0" } -"diff.conflict.added" = { bg = "#d0ead0" } +"diff.conflict.removed" = { bg = "#f2d0d0" } +"diff.conflict.added" = { bg = "#d0ead0" } +"diff.conflict.marker" = { bg = "#f0f0f0" } +"diff.conflict.current" = { bg = "#f8f0f0" } +"diff.conflict.base" = { bg = "#f0f0f0" } +"diff.conflict.incoming" = { bg = "#f0f8f0" } "diagnostic" = { modifiers = ["underlined"] } "ui.gutter" = { bg = "base01" } diff --git a/runtime/themes/tokyonight.toml b/runtime/themes/tokyonight.toml index 26019bdd..ca2146ba 100644 --- a/runtime/themes/tokyonight.toml +++ b/runtime/themes/tokyonight.toml @@ -57,8 +57,12 @@ variable = { fg = "fg" } "diff.delta.moved" = { fg = "blue" } "diff.minus" = { fg = "delete" } "diff.plus" = { fg = "add" } -"diff.conflict.removed" = { bg = "#372731" } -"diff.conflict.added" = { bg = "#243b47" } +"diff.conflict.removed" = { bg = "#662644" } +"diff.conflict.added" = { bg = "#005572" } +"diff.conflict.marker" = { bg = "#1f242e" } +"diff.conflict.current" = { bg = "#092334" } +"diff.conflict.base" = { bg = "#1e2025" } +"diff.conflict.incoming" = { bg = "#142610" } error = { fg = "error" } warning = { fg = "yellow" } diff --git a/theme.toml b/theme.toml index 2139c8bf..24ffe620 100644 --- a/theme.toml +++ b/theme.toml @@ -40,6 +40,10 @@ tabstop = { modifiers = ["italic"], bg = "bossanova" } "diff.plus" = "#35bf86" "diff.minus" = "#f22c86" "diff.delta" = "#6f44f0" +"diff.conflict.marker" = { bg = "#463553" } +"diff.conflict.current" = { bg = "#442b56" } +"diff.conflict.base" = { bg = "#382e40" } +"diff.conflict.incoming" = { bg = "#382e53" } # TODO: differentiate doc comment # concat (ERROR) @error.syntax and "MISSING ;" selectors for errors