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/// # Contract
38///
39/// - Ensures: result length is `<= max_rows`; `max_rows == 0` yields empty.
40/// - Ensures: when omitting, exactly one `Metadata` marker
41///   (`... N lines omitted ...`) separates retained head and tail, and
42///   retained entries keep their source numbers and intraline ranges.
43/// - Termination: no loops over unbounded state (two bounded copies).
44#[must_use]
45pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
46    if max_rows == 0 {
47        return Vec::new();
48    }
49    if lines.len() <= max_rows {
50        return lines.to_vec();
51    }
52
53    let retained = max_rows.saturating_sub(1);
54    let head_count = retained.saturating_add(1) / 2;
55    let tail_count = retained / 2;
56    let omitted = lines.len().saturating_sub(head_count + tail_count);
57
58    let mut bounded = Vec::with_capacity(max_rows);
59    bounded.extend_from_slice(&lines[..head_count]);
60    bounded.push(DiffDisplayLine::body(
61        DiffDisplayKind::Metadata,
62        None,
63        None,
64        format!("... {omitted} lines omitted ..."),
65    ));
66    if tail_count > 0 {
67        bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
68    }
69    bounded
70}
71
72fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
73    let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
74    let content_width = options.width.saturating_sub(gutter_width).max(1);
75    let mut hunk_index = None;
76    for line in lines {
77        if line.kind == DiffDisplayKind::Metadata {
78            rows.push(metadata_row(&line.text, hunk_index));
79            continue;
80        }
81        if line.kind == DiffDisplayKind::HunkHeader {
82            hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
83            rows.push(header_row(&line.text, hunk_index));
84            continue;
85        }
86        let marker = marker_for_kind(line.kind);
87        for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
88            .into_iter()
89            .enumerate()
90        {
91            rows.push(DiffRow {
92                kind: row_kind(line.kind),
93                marker,
94                hunk_index,
95                continuation: continuation > 0,
96                left: Some(DiffCell {
97                    old_line: (continuation == 0).then_some(line.old_line).flatten(),
98                    new_line: (continuation == 0).then_some(line.new_line).flatten(),
99                    marker,
100                    segments,
101                }),
102                right: None,
103            });
104        }
105    }
106}
107
108fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
109    let pane_width = options.width.saturating_sub(1) / 2;
110    let content_width = pane_width.saturating_sub(6).max(1);
111    let mut hunk_index = None;
112    for_each_side_by_side_pair(lines, |left, right| {
113        let is_full_width = right.is_none()
114            && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
115        if is_full_width {
116            let text = left.map_or("", |line| line.text.as_str());
117            if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
118                hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
119                rows.push(header_row(text, hunk_index));
120            } else {
121                rows.push(metadata_row(text, hunk_index));
122            }
123            return;
124        }
125        let left_parts = left.map_or_else(
126            || vec![Vec::new()],
127            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
128        );
129        let right_parts = right.map_or_else(
130            || vec![Vec::new()],
131            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
132        );
133        let count = left_parts.len().max(right_parts.len());
134        for offset in 0..count {
135            let left_cell = left.and_then(|line| {
136                left_parts.get(offset).map(|segments| DiffCell {
137                    old_line: (offset == 0).then_some(line.old_line).flatten(),
138                    new_line: (offset == 0).then_some(line.new_line).flatten(),
139                    marker: marker_for_kind(line.kind),
140                    segments: segments.clone(),
141                })
142            });
143            let right_cell = right.and_then(|line| {
144                right_parts.get(offset).map(|segments| DiffCell {
145                    old_line: (offset == 0).then_some(line.old_line).flatten(),
146                    new_line: (offset == 0).then_some(line.new_line).flatten(),
147                    marker: marker_for_kind(line.kind),
148                    segments: segments.clone(),
149                })
150            });
151            let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
152            rows.push(DiffRow {
153                kind,
154                marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
155                hunk_index,
156                continuation: offset > 0,
157                left: left_cell,
158                right: right_cell,
159            });
160        }
161    });
162}
163
164fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
165    DiffRow {
166        kind: DiffRowKind::HunkHeader,
167        marker: '@',
168        hunk_index,
169        continuation: false,
170        left: Some(DiffCell {
171            old_line: None,
172            new_line: None,
173            marker: '@',
174            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
175        }),
176        right: None,
177    }
178}
179
180fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
181    DiffRow {
182        kind: DiffRowKind::Metadata,
183        marker: ' ',
184        hunk_index,
185        continuation: false,
186        left: Some(DiffCell {
187            old_line: None,
188            new_line: None,
189            marker: ' ',
190            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
191        }),
192        right: None,
193    }
194}
195
196struct RowCollector {
197    head: Vec<DiffRow>,
198    tail: VecDeque<DiffRow>,
199    max_rows: usize,
200    total_rows: usize,
201    overflowed: bool,
202}
203
204impl RowCollector {
205    fn new(max_rows: usize) -> Self {
206        Self {
207            head: Vec::new(),
208            tail: VecDeque::new(),
209            max_rows,
210            total_rows: 0,
211            overflowed: false,
212        }
213    }
214
215    fn push(&mut self, row: DiffRow) {
216        self.total_rows = self.total_rows.saturating_add(1);
217        if self.max_rows == 0 {
218            return;
219        }
220        if !self.overflowed {
221            self.head.push(row);
222            if self.head.len() <= self.max_rows {
223                return;
224            }
225
226            let retained = self.max_rows.saturating_sub(1);
227            let head_count = retained.saturating_add(1) / 2;
228            let tail_count = retained / 2;
229            if tail_count > 0 {
230                let tail_start = self.head.len() - tail_count;
231                self.tail = self.head.split_off(tail_start).into();
232            }
233            self.head.truncate(head_count);
234            self.overflowed = true;
235            return;
236        }
237
238        let tail_count = self.max_rows.saturating_sub(1) / 2;
239        if tail_count > 0 {
240            if self.tail.len() == tail_count {
241                let _ = self.tail.pop_front();
242            }
243            self.tail.push_back(row);
244        }
245    }
246
247    fn finish(mut self) -> Vec<DiffRow> {
248        if !self.overflowed {
249            return self.head;
250        }
251        let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
252        self.head.push(omission_row(omitted));
253        self.head.extend(self.tail);
254        self.head
255    }
256}
257
258fn omission_row(omitted: usize) -> DiffRow {
259    DiffRow {
260        kind: DiffRowKind::Omission,
261        marker: '…',
262        hunk_index: None,
263        continuation: false,
264        left: Some(DiffCell {
265            old_line: None,
266            new_line: None,
267            marker: '…',
268            segments: vec![DiffSegment {
269                text: format!("{omitted} rows omitted"),
270                emphasized: false,
271            }],
272        }),
273        right: None,
274    }
275}
276
277fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
278    // Contract: `changed` ranges should be char-boundary ranges within `text`
279    // (invalid ones are ignored by `segment_slice`). Ensures at least one row.
280    // Termination: single char_indices pass (variant: remaining chars).
281    if !wrap || UnicodeWidthStr::width(text) <= width {
282        return vec![segment_slice(text, changed, 0, text.len())];
283    }
284    let mut rows = Vec::new();
285    let mut byte_start = 0usize;
286    let mut display_width = 0usize;
287    for (byte, character) in text.char_indices() {
288        let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
289        if display_width > 0 && display_width.saturating_add(char_width) > width {
290            rows.push(segment_slice(text, changed, byte_start, byte));
291            byte_start = byte;
292            display_width = 0;
293        }
294        display_width = display_width.saturating_add(char_width);
295    }
296    rows.push(segment_slice(text, changed, byte_start, text.len()));
297    rows
298}
299
300fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
301    if start == end {
302        return vec![DiffSegment { text: String::new(), emphasized: false }];
303    }
304    let mut boundaries = vec![start, end];
305    for &(range_start, range_end) in changed {
306        if range_start < end && range_end > start {
307            let bounded_start = range_start.max(start).min(end);
308            let bounded_end = range_end.max(start).min(end);
309            if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
310                boundaries.push(bounded_start);
311                boundaries.push(bounded_end);
312            }
313        }
314    }
315    boundaries.sort_unstable();
316    boundaries.dedup();
317    boundaries
318        .windows(2)
319        .filter_map(|pair| {
320            let segment_start = pair[0];
321            let segment_end = pair[1];
322            (segment_start < segment_end).then(|| DiffSegment {
323                text: text[segment_start..segment_end].to_owned(),
324                emphasized: changed
325                    .iter()
326                    .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
327            })
328        })
329        .collect()
330}
331
332fn marker_for_kind(kind: DiffDisplayKind) -> char {
333    match kind {
334        DiffDisplayKind::Addition => '+',
335        DiffDisplayKind::Deletion => '-',
336        DiffDisplayKind::HunkHeader => '@',
337        DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
338    }
339}
340
341fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
342    match kind {
343        DiffDisplayKind::Addition => DiffRowKind::Addition,
344        DiffDisplayKind::Deletion => DiffRowKind::Deletion,
345        DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
346        DiffDisplayKind::Metadata => DiffRowKind::Metadata,
347        DiffDisplayKind::Context => DiffRowKind::Context,
348    }
349}