fix: overlay clipped scope labels without reflow

This commit is contained in:
Valdemar Lorenzen
2026-10-16 11:02:44 +02:00
parent e37fa509c9
commit 682427324d
3 changed files with 215 additions and 62 deletions
+212 -59
View File
@@ -3774,7 +3774,6 @@ fn draw_diff(
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.lefts = [before_left, after_left];
viewport.update(
diff,
file,
@@ -3958,6 +3957,22 @@ fn draw_diff(
content,
);
}
if let (Some(file), Some(syntax), Some(viewport)) = (
selected.filter(|file| file.loaded),
panels.diff_syntax.as_ref(),
panels.diff_wrap.as_ref(),
) {
draw_diff_scope_labels(
frame,
diff,
file,
syntax,
viewport,
[before_area, after_area],
[before_left, after_left],
&mut document_caret,
);
}
panels.diff = Some(DiffGeometry {
regions: [
(navigator.unwrap_or_default(), Focus::Files),
@@ -4090,6 +4105,116 @@ fn diff_pane_panel(
diff_panel(frame, area, &title, access, &revision, diff.focus == focus)
}
#[allow(clippy::too_many_arguments)]
fn draw_diff_scope_labels(
frame: &mut Frame,
diff: &DiffState,
file: &ChangedFile,
syntax: &DiffSyntaxCache,
viewport: &DiffWrapViewport,
areas: [Rect; 2],
lefts: [usize; 2],
caret: &mut Option<(Rect, Position)>,
) {
let unified = diff.view_mode == ViewMode::Unified;
for (pane, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
if areas[pane].is_empty() || (unified && (diff.focus == Focus::Before) != (pane == 0)) {
continue;
}
let content = diff_inner(areas[pane]);
let gutter = diff_gutter(diff, file, usize::from(content.width), focus);
let width = usize::from(content.width).saturating_sub(gutter);
for (screen, row) in viewport
.rows
.iter()
.take(usize::from(content.height))
.enumerate()
{
let overlay = row.scope == [false; 2];
if overlay && !row.unwrapped {
continue;
}
let contexts = syntax.scopes(diff, row.position.display);
let side = if !unified {
pane
} else if overlay {
usize::from(diff.display_focus(row.position.display) != Some(Focus::Before))
} else {
usize::from(!row.scope[0])
};
let Some(scope) = contexts[side] else {
continue;
};
let y = content.y + screen as u16;
let y = if overlay {
let source_focus = if side == 0 {
Focus::Before
} else {
Focus::After
};
if diff.display_source_row(source_focus, row.position.display)
!= Some(scope.name.row)
{
continue;
}
let source = if side == 0 { &file.before } else { &file.after };
let prefix = &source[scope.name.row][..scope.name.column];
let column = display_column(prefix, usize::MAX);
if lefts[pane] <= column && column < lefts[pane].saturating_add(width) {
continue;
}
let Some(y) = y.checked_sub(1).filter(|&y| y >= areas[pane].y) else {
continue;
};
y
} else {
if !row.scope[side] {
continue;
}
y
};
let shared = if overlay {
contexts[0].map(|scope| scope.label) == contexts[1].map(|scope| scope.label)
} else {
row.scope == [true; 2]
};
let prefix = if width < 4 {
""
} else if !unified {
"› "
} else if shared {
"± "
} else if side == 0 {
"− "
} else {
"+ "
};
let prefix_width = UnicodeWidthStr::width(prefix);
let available = width.saturating_sub(prefix_width);
let label = if UnicodeWidthStr::width(scope.label) > available {
scope.label.rsplit(" › ").next().unwrap_or(scope.label)
} else {
scope.label
};
let style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC);
let x = content.x + gutter as u16;
let buffer = frame.buffer_mut();
for x in content.x..content.right() {
buffer[(x, y)].reset();
}
buffer.set_stringn(x, y, prefix, width, style);
buffer.set_stringn(x + prefix_width as u16, y, label, available, style);
if caret.is_some_and(|(_, position)| {
position.y == y && content.x <= position.x && position.x < content.right()
}) {
*caret = None;
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_diff_pane(
frame: &mut Frame,
@@ -4142,58 +4267,6 @@ fn draw_diff_pane(
*left = diff.bound_horizontal(focus, *left, width);
}
let left = *left;
if let Some(rows) = wrapped {
for (screen, row) in rows.iter().take(usize::from(content.height)).enumerate() {
if row.scope == [false; 2] {
continue;
}
let side = if unified {
usize::from(!row.scope[0])
} else {
usize::from(!before)
};
if !row.scope[side] {
continue;
}
let source_focus = if side == 0 {
Focus::Before
} else {
Focus::After
};
let Some(scope) = diff
.display_source_row(source_focus, row.position.display)
.and_then(|source| syntax[side].scope_at(source))
else {
continue;
};
let prefix = if width < 4 {
""
} else if !unified {
"› "
} else if row.scope == [true; 2] {
"± "
} else if side == 0 {
"− "
} else {
"+ "
};
let prefix_width = UnicodeWidthStr::width(prefix);
let available = width.saturating_sub(prefix_width);
let label = if UnicodeWidthStr::width(scope.label) > available {
scope.label.rsplit(" › ").next().unwrap_or(scope.label)
} else {
scope.label
};
let style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC);
let x = content.x + gutter as u16;
let y = content.y + screen as u16;
let buffer = frame.buffer_mut();
buffer.set_stringn(x, y, prefix, width, style);
buffer.set_stringn(x + prefix_width as u16, y, label, available, style);
}
}
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
let row = &diff.display_rows[display_row];
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
@@ -4463,7 +4536,6 @@ struct DiffWrapViewport {
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
widths: [usize; 2],
lefts: [usize; 2],
heights: [usize; 2],
visible_names: [Vec<Point>; 2],
top: DiffWrapPosition,
@@ -4628,13 +4700,13 @@ impl DiffWrapViewport {
{
return false;
}
let (left, right) = if row.unwrapped {
(self.lefts[side], self.lefts[side] + self.widths[side])
} else if let Some(segment) = row.segments[side] {
(segment.start, segment.end)
} else {
if row.unwrapped {
return true;
}
let Some(segment) = row.segments[side] else {
return false;
};
let (left, right) = (segment.start, segment.end);
left <= column && column < right
})
}
@@ -8189,6 +8261,87 @@ mod tests {
assert_eq!(viewport.rows[0].scope, [false, true]);
}
#[test]
fn diff_scope_horizontal_scroll_keeps_source_rows_fixed() {
let before =
"keep_previous_code();\nfn review() {\n old_call_with_a_long_argument_name();\n}\n";
let after = before.replace("old_call", "new_call");
for (mode, axis, focus) in [
(ViewMode::Split, SplitAxis::Vertical, Focus::After),
(ViewMode::Split, SplitAxis::Horizontal, Focus::After),
(ViewMode::Unified, SplitAxis::Vertical, Focus::Before),
(ViewMode::Unified, SplitAxis::Vertical, Focus::After),
] {
let mut file = syntax_diff_file("scope.rs", before, &after);
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.split_axis = axis;
diff.set_view_mode(mode);
diff.focus = focus;
let declaration = diff.source_display_row(Focus::After, 1).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut panels = WheelPanels::default();
// A declaration at the top uses the border rather than displacing its first row.
for top in [0, declaration] {
diff.top = top;
let mut original = None;
for lefts in [[0, 0], [8, 0], [0, 8], [8, 8], [0, 0]] {
[diff.left_before, diff.left_after] = lefts;
render_diff(&mut terminal, &diff, &mut panels);
let viewport = panels.diff_wrap.as_ref().unwrap();
let source_rows = viewport
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.scope == [false; 2])
.map(|(screen, row)| (screen, row.position))
.collect::<Vec<_>>();
if let Some(original) = &original {
assert_eq!(
&source_rows, original,
"Horizontal scrolling must not move source rows: {mode:?}, {axis:?}"
);
} else {
original = Some(source_rows);
}
let screen = viewport
.screen_row(DiffWrapPosition {
display: declaration,
continuation: 0,
})
.unwrap();
for (pane, left) in lefts.into_iter().enumerate() {
if mode == ViewMode::Unified && (focus == Focus::Before) != (pane == 0) {
continue;
}
let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0);
let above = pane_screen(
&terminal,
Rect::new(area.x, area.y + screen as u16 - 1, area.width, 1),
);
assert_eq!(
above.contains("review"),
left > 0,
"Clipped names belong above the declaration, without adding a row"
);
if left > 0 {
assert_eq!(
above.trim(),
if mode == ViewMode::Unified {
"± review"
} else {
"› review"
},
"The overlay must clear source text and its line number"
);
}
}
}
}
}
}
#[test]
fn diff_scope_labels_follow_declaration_visibility() {
let before = "@decorate()\nclass PolicyActiveStatusEnum(BaseEnum):\n ACTIVE = 1\n EXPIRED = 2\n FUTURE = 3\n UNKNOWN = 4\n";