Skip to main content

vtcode_diff/
layout.rs

1//! Renderer-neutral semantic row layout with bounded wrapping.
2
3use std::collections::VecDeque;
4
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7use super::display::{display_lines_from_hunks_with_timeout, for_each_side_by_side_pair};
8use super::format::diff_display_line_number_width;
9use super::types::{
10    DiffCell, DiffDisplayKind, DiffDisplayLine, DiffDocument, DiffLayout, DiffRow, DiffRowKind, DiffSegment,
11    LayoutOptions,
12};
13
14pub(crate) fn layout_document(document: &DiffDocument, options: LayoutOptions) -> Vec<DiffRow> {
15    let display = display_lines_from_hunks_with_timeout(&document.hunks, document.inline_timeout);
16    layout_display_lines(&display, options)
17}
18
19/// Lays out precomputed display lines without repeating intraline analysis.
20///
21/// Interactive applications can cache semantic lines when an overlay opens
22/// and call this inexpensive step again after a resize.
23#[must_use]
24pub fn layout_display_lines(display: &[DiffDisplayLine], options: LayoutOptions) -> Vec<DiffRow> {
25    let use_side_by_side = options.layout == DiffLayout::SideBySide && options.width >= options.min_side_by_side_width;
26    let mut rows = RowCollector::new(options.max_rows);
27    if use_side_by_side {
28        layout_side_by_side(display, options, &mut rows);
29    } else {
30        layout_unified(display, options, &mut rows);
31    }
32    rows.finish()
33}
34
35/// Returns a bounded head/tail excerpt of semantic display lines.
36///
37/// The returned vector contains at most `max_rows` entries. When rows are
38/// omitted, one metadata entry (`... N lines omitted ...`) is inserted between
39/// the retained head and tail. Source line numbers and intraline ranges on
40/// retained entries are preserved, so callers can render the excerpt without
41/// reparsing or inventing positions.
42#[must_use]
43pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
44    if max_rows == 0 {
45        return Vec::new();
46    }
47    if lines.len() <= max_rows {
48        return lines.to_vec();
49    }
50
51    let retained = max_rows.saturating_sub(1);
52    let head_count = retained.saturating_add(1) / 2;
53    let tail_count = retained / 2;
54    let omitted = lines.len().saturating_sub(head_count + tail_count);
55
56    let mut bounded = Vec::with_capacity(max_rows);
57    bounded.extend_from_slice(&lines[..head_count]);
58    bounded.push(DiffDisplayLine::body(
59        DiffDisplayKind::Metadata,
60        None,
61        None,
62        format!("... {omitted} lines omitted ..."),
63    ));
64    if tail_count > 0 {
65        bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
66    }
67    bounded
68}
69
70fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
71    let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
72    let content_width = options.width.saturating_sub(gutter_width).max(1);
73    let mut hunk_index = None;
74    for line in lines {
75        if line.kind == DiffDisplayKind::Metadata {
76            rows.push(metadata_row(&line.text, hunk_index));
77            continue;
78        }
79        if line.kind == DiffDisplayKind::HunkHeader {
80            hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
81            rows.push(header_row(&line.text, hunk_index));
82            continue;
83        }
84        let marker = marker_for_kind(line.kind);
85        for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
86            .into_iter()
87            .enumerate()
88        {
89            rows.push(DiffRow {
90                kind: row_kind(line.kind),
91                marker,
92                hunk_index,
93                continuation: continuation > 0,
94                left: Some(DiffCell {
95                    old_line: (continuation == 0).then_some(line.old_line).flatten(),
96                    new_line: (continuation == 0).then_some(line.new_line).flatten(),
97                    marker,
98                    segments,
99                }),
100                right: None,
101            });
102        }
103    }
104}
105
106fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
107    let pane_width = options.width.saturating_sub(1) / 2;
108    let content_width = pane_width.saturating_sub(6).max(1);
109    let mut hunk_index = None;
110    for_each_side_by_side_pair(lines, |left, right| {
111        let is_full_width = right.is_none()
112            && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
113        if is_full_width {
114            let text = left.map_or("", |line| line.text.as_str());
115            if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
116                hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
117                rows.push(header_row(text, hunk_index));
118            } else {
119                rows.push(metadata_row(text, hunk_index));
120            }
121            return;
122        }
123        let left_parts = left.map_or_else(
124            || vec![Vec::new()],
125            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
126        );
127        let right_parts = right.map_or_else(
128            || vec![Vec::new()],
129            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
130        );
131        let count = left_parts.len().max(right_parts.len());
132        for offset in 0..count {
133            let left_cell = left.and_then(|line| {
134                left_parts.get(offset).map(|segments| DiffCell {
135                    old_line: (offset == 0).then_some(line.old_line).flatten(),
136                    new_line: (offset == 0).then_some(line.new_line).flatten(),
137                    marker: marker_for_kind(line.kind),
138                    segments: segments.clone(),
139                })
140            });
141            let right_cell = right.and_then(|line| {
142                right_parts.get(offset).map(|segments| DiffCell {
143                    old_line: (offset == 0).then_some(line.old_line).flatten(),
144                    new_line: (offset == 0).then_some(line.new_line).flatten(),
145                    marker: marker_for_kind(line.kind),
146                    segments: segments.clone(),
147                })
148            });
149            let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
150            rows.push(DiffRow {
151                kind,
152                marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
153                hunk_index,
154                continuation: offset > 0,
155                left: left_cell,
156                right: right_cell,
157            });
158        }
159    });
160}
161
162fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
163    DiffRow {
164        kind: DiffRowKind::HunkHeader,
165        marker: '@',
166        hunk_index,
167        continuation: false,
168        left: Some(DiffCell {
169            old_line: None,
170            new_line: None,
171            marker: '@',
172            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
173        }),
174        right: None,
175    }
176}
177
178fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
179    DiffRow {
180        kind: DiffRowKind::Metadata,
181        marker: ' ',
182        hunk_index,
183        continuation: false,
184        left: Some(DiffCell {
185            old_line: None,
186            new_line: None,
187            marker: ' ',
188            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
189        }),
190        right: None,
191    }
192}
193
194struct RowCollector {
195    head: Vec<DiffRow>,
196    tail: VecDeque<DiffRow>,
197    max_rows: usize,
198    total_rows: usize,
199    overflowed: bool,
200}
201
202impl RowCollector {
203    fn new(max_rows: usize) -> Self {
204        Self {
205            head: Vec::new(),
206            tail: VecDeque::new(),
207            max_rows,
208            total_rows: 0,
209            overflowed: false,
210        }
211    }
212
213    fn push(&mut self, row: DiffRow) {
214        self.total_rows = self.total_rows.saturating_add(1);
215        if self.max_rows == 0 {
216            return;
217        }
218        if !self.overflowed {
219            self.head.push(row);
220            if self.head.len() <= self.max_rows {
221                return;
222            }
223
224            let retained = self.max_rows.saturating_sub(1);
225            let head_count = retained.saturating_add(1) / 2;
226            let tail_count = retained / 2;
227            if tail_count > 0 {
228                let tail_start = self.head.len() - tail_count;
229                self.tail = self.head.split_off(tail_start).into();
230            }
231            self.head.truncate(head_count);
232            self.overflowed = true;
233            return;
234        }
235
236        let tail_count = self.max_rows.saturating_sub(1) / 2;
237        if tail_count > 0 {
238            if self.tail.len() == tail_count {
239                let _ = self.tail.pop_front();
240            }
241            self.tail.push_back(row);
242        }
243    }
244
245    fn finish(mut self) -> Vec<DiffRow> {
246        if !self.overflowed {
247            return self.head;
248        }
249        let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
250        self.head.push(omission_row(omitted));
251        self.head.extend(self.tail);
252        self.head
253    }
254}
255
256fn omission_row(omitted: usize) -> DiffRow {
257    DiffRow {
258        kind: DiffRowKind::Omission,
259        marker: '…',
260        hunk_index: None,
261        continuation: false,
262        left: Some(DiffCell {
263            old_line: None,
264            new_line: None,
265            marker: '…',
266            segments: vec![DiffSegment {
267                text: format!("{omitted} rows omitted"),
268                emphasized: false,
269            }],
270        }),
271        right: None,
272    }
273}
274
275fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
276    if !wrap || UnicodeWidthStr::width(text) <= width {
277        return vec![segment_slice(text, changed, 0, text.len())];
278    }
279    let mut rows = Vec::new();
280    let mut byte_start = 0usize;
281    let mut display_width = 0usize;
282    for (byte, character) in text.char_indices() {
283        let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
284        if display_width > 0 && display_width.saturating_add(char_width) > width {
285            rows.push(segment_slice(text, changed, byte_start, byte));
286            byte_start = byte;
287            display_width = 0;
288        }
289        display_width = display_width.saturating_add(char_width);
290    }
291    rows.push(segment_slice(text, changed, byte_start, text.len()));
292    rows
293}
294
295fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
296    if start == end {
297        return vec![DiffSegment { text: String::new(), emphasized: false }];
298    }
299    let mut boundaries = vec![start, end];
300    for &(range_start, range_end) in changed {
301        if range_start < end && range_end > start {
302            let bounded_start = range_start.max(start).min(end);
303            let bounded_end = range_end.max(start).min(end);
304            if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
305                boundaries.push(bounded_start);
306                boundaries.push(bounded_end);
307            }
308        }
309    }
310    boundaries.sort_unstable();
311    boundaries.dedup();
312    boundaries
313        .windows(2)
314        .filter_map(|pair| {
315            let segment_start = pair[0];
316            let segment_end = pair[1];
317            (segment_start < segment_end).then(|| DiffSegment {
318                text: text[segment_start..segment_end].to_owned(),
319                emphasized: changed
320                    .iter()
321                    .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
322            })
323        })
324        .collect()
325}
326
327fn marker_for_kind(kind: DiffDisplayKind) -> char {
328    match kind {
329        DiffDisplayKind::Addition => '+',
330        DiffDisplayKind::Deletion => '-',
331        DiffDisplayKind::HunkHeader => '@',
332        DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
333    }
334}
335
336fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
337    match kind {
338        DiffDisplayKind::Addition => DiffRowKind::Addition,
339        DiffDisplayKind::Deletion => DiffRowKind::Deletion,
340        DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
341        DiffDisplayKind::Metadata => DiffRowKind::Metadata,
342        DiffDisplayKind::Context => DiffRowKind::Context,
343    }
344}