Skip to main content

tess/
pane.rs

1//! Split-view layout. The FOCUSED pane lives in `app::run`'s loose locals; this
2//! module bundles the OTHER pane (`Pane`) and provides the pure compositor that
3//! stitches two half-width frames into one full-width frame. No terminal I/O.
4
5use crate::line_index::LineIndex;
6use crate::render::Cell;
7use crate::source::Source;
8use crate::viewport::{Frame, RowStyle, Viewport};
9
10/// Width of the inter-pane divider, in columns.
11pub const DIVIDER: usize = 1;
12
13/// One side of a split: its own source, index, viewport, and per-pane
14/// follow/animation bookkeeping. The focused pane lives in app::run's loose
15/// locals; this is the stashed partner swapped in on focus change.
16pub struct Pane {
17    pub src: Box<dyn Source>,
18    pub idx: LineIndex,
19    pub viewport: Viewport,
20    pub last_revision: u64,
21    #[cfg(feature = "image")]
22    pub last_tick: std::time::Instant,
23}
24
25/// Left/right content widths for a split at `cols` columns (1-col divider).
26/// Right gets the extra column on odd widths. Returns `(cols, 0)` when there's
27/// no room to split — caller renders the focused pane full-width.
28pub fn split_widths(cols: u16) -> (u16, u16) {
29    const MIN: usize = 8; // each pane needs a usable minimum
30    let c = cols as usize;
31    if c < 2 * MIN + DIVIDER {
32        return (cols, 0);
33    }
34    let usable = c - DIVIDER;
35    let left = usable / 2;
36    (left as u16, (usable - left) as u16)
37}
38
39fn divider_cell() -> Cell {
40    Cell::Char {
41        ch: '\u{2502}', // │
42        width: 1,
43        style: crate::ansi::Style { dim: true, ..Default::default() },
44        hyperlink: None,
45    }
46}
47
48/// `--dim` is a per-row style, but a merged row spans two panes that may differ.
49/// Flatten a pane's row-level dim into its cells so the merged row can carry it.
50fn flatten_dim(cells: &mut [Cell]) {
51    for c in cells.iter_mut() {
52        if let Cell::Char { style, .. } = c {
53            style.dim = true;
54        }
55    }
56}
57
58/// Fit a pane's status to `w` display columns, prefixing the focused pane's with
59/// a `*` marker. Width-aware (so `×`/`»` glyphs count as 1).
60fn fit_pane_status(s: &str, w: usize, focused: bool) -> String {
61    use unicode_width::UnicodeWidthChar;
62    let marked = if focused { format!("*{s}") } else { s.to_string() };
63    let mut out = String::with_capacity(w);
64    let mut width = 0usize;
65    for ch in marked.chars() {
66        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
67        if width + cw > w {
68            break;
69        }
70        out.push(ch);
71        width += cw;
72    }
73    for _ in width..w {
74        out.push(' ');
75    }
76    out
77}
78
79/// Stitch two half-width pane frames into one full-width frame:
80/// `left cells | divider | right cells` per body row, per-pane statuses joined,
81/// right pane's highlight ranges shifted past the divider, row-level dim
82/// flattened into cells. Pure.
83pub fn compose_split(left: &Frame, right: &Frame, left_w: u16, cols: u16, focused_left: bool) -> Frame {
84    let lw = left_w as usize;
85    let rw = (cols as usize).saturating_sub(lw + DIVIDER);
86    let body_rows = left.body.len().max(right.body.len());
87    let mut body = Vec::with_capacity(body_rows);
88    let mut highlights = Vec::with_capacity(body_rows);
89    let empty_row: Vec<Cell> = Vec::new();
90    let no_hl: Vec<std::ops::Range<usize>> = Vec::new();
91    for r in 0..body_rows {
92        let mut lcells = left.body.get(r).cloned().unwrap_or_else(|| empty_row.clone());
93        lcells.resize(lw, Cell::Empty);
94        if left.row_styles.get(r) == Some(&RowStyle::Dim) {
95            flatten_dim(&mut lcells);
96        }
97        let mut rcells = right.body.get(r).cloned().unwrap_or_else(|| empty_row.clone());
98        rcells.resize(rw, Cell::Empty);
99        if right.row_styles.get(r) == Some(&RowStyle::Dim) {
100            flatten_dim(&mut rcells);
101        }
102        let mut row = Vec::with_capacity(cols as usize);
103        row.extend(lcells);
104        row.push(divider_cell());
105        row.extend(rcells);
106        body.push(row);
107
108        let off = lw + DIVIDER;
109        let mut hl = left.highlights.get(r).cloned().unwrap_or_else(|| no_hl.clone());
110        if let Some(rh) = right.highlights.get(r) {
111            hl.extend(rh.iter().map(|x| (x.start + off)..(x.end + off)));
112        }
113        highlights.push(hl);
114    }
115    let lstat = fit_pane_status(&left.status, lw, focused_left);
116    let rstat = fit_pane_status(&right.status, rw, !focused_left);
117    let status = format!("{lstat}\u{2502}{rstat}");
118    Frame {
119        body,
120        row_styles: vec![RowStyle::Normal; body_rows],
121        highlights,
122        status,
123        status_style: left.status_style,
124        raw_rows: vec![None; body_rows],
125        image_blob: None,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::ansi::Style;
133
134    fn cell(ch: char) -> Cell {
135        Cell::Char { ch, width: 1, style: Style::default(), hyperlink: None }
136    }
137    fn frame(rows: Vec<Vec<Cell>>, status: &str) -> Frame {
138        let n = rows.len();
139        Frame {
140            body: rows,
141            row_styles: vec![RowStyle::Normal; n],
142            highlights: vec![Vec::new(); n],
143            status: status.to_string(),
144            status_style: Style::default(),
145            raw_rows: vec![None; n],
146            image_blob: None,
147        }
148    }
149
150    #[test]
151    fn split_widths_even_odd_and_too_small() {
152        assert_eq!(split_widths(33), (16, 16));
153        assert_eq!(split_widths(34), (16, 17));
154        assert_eq!(split_widths(10), (10, 0));
155    }
156
157    #[test]
158    fn compose_stitches_rows_with_divider() {
159        let l = frame(vec![vec![cell('a'), cell('b')]], "L");
160        let r = frame(vec![vec![cell('x'), cell('y')]], "R");
161        let m = compose_split(&l, &r, 2, 5, true);
162        assert_eq!(m.body.len(), 1);
163        let row = &m.body[0];
164        assert_eq!(row.len(), 5);
165        assert!(matches!(row[0], Cell::Char { ch: 'a', .. }));
166        assert!(matches!(row[1], Cell::Char { ch: 'b', .. }));
167        assert!(matches!(row[2], Cell::Char { ch: '\u{2502}', .. }), "divider at col 2");
168        assert!(matches!(row[3], Cell::Char { ch: 'x', .. }));
169        assert!(matches!(row[4], Cell::Char { ch: 'y', .. }));
170        assert!(m.status.starts_with("*L"), "focused-left status marked: {:?}", m.status);
171        assert!(m.status.contains('\u{2502}'));
172    }
173
174    #[test]
175    fn right_pane_highlights_shifted_past_divider() {
176        let l = frame(vec![vec![cell('a'), cell('b')]], "L");
177        let mut r = frame(vec![vec![cell('x'), cell('y')]], "R");
178        r.highlights[0] = vec![0..1];
179        let m = compose_split(&l, &r, 2, 5, true);
180        assert_eq!(m.highlights[0], vec![3..4]);
181    }
182
183    #[test]
184    fn dim_row_flattened_into_cells() {
185        let mut l = frame(vec![vec![cell('a')]], "L");
186        l.row_styles[0] = RowStyle::Dim;
187        let r = frame(vec![vec![cell('x')]], "R");
188        let m = compose_split(&l, &r, 1, 3, true);
189        match &m.body[0][0] {
190            Cell::Char { style, .. } => assert!(style.dim, "left dim flattened into cell"),
191            _ => panic!(),
192        }
193        assert_eq!(m.row_styles[0], RowStyle::Normal, "merged row style is Normal");
194    }
195
196    #[test]
197    fn focused_right_marks_right_status() {
198        // cols=5 so the right pane has width 2 — room for the `*R` marker.
199        // (At cols=3 the 1-col right pane can only hold `*`, truncating the `R`.)
200        let l = frame(vec![vec![cell('a'), cell('b')]], "L");
201        let r = frame(vec![vec![cell('x'), cell('y')]], "R");
202        let m = compose_split(&l, &r, 2, 5, false);
203        assert!(m.status.contains("\u{2502}*R"), "focused-right status marked: {:?}", m.status);
204    }
205
206    #[test]
207    fn uneven_body_rows_pad_with_empty() {
208        // Left has 2 rows, right has 1: merged row 1's right half must be all Empty,
209        // and every merged row is exactly left_w + divider + right_w cells.
210        let l = frame(vec![vec![cell('a')], vec![cell('b')]], "L"); // 2 rows
211        let r = frame(vec![vec![cell('x')]], "R");                  // 1 row
212        let m = compose_split(&l, &r, 1, 3, true); // lw=1, rw = 3-(1+1)=1
213        assert_eq!(m.body.len(), 2, "merged uses the taller pane's row count");
214        for row in &m.body {
215            assert_eq!(row.len(), 3, "each merged row is lw + divider + rw");
216            assert!(matches!(row[1], Cell::Char { ch: '\u{2502}', .. }), "divider at col 1");
217        }
218        // Row 1: left 'b', divider, right padded Empty.
219        assert!(matches!(m.body[1][0], Cell::Char { ch: 'b', .. }));
220        assert!(matches!(m.body[1][2], Cell::Empty), "missing right row → Empty pad");
221    }
222
223    #[test]
224    fn pane_status_truncates_to_width() {
225        // A status longer than the pane width is fit-truncated by display columns.
226        // lw=4: focused-left status "*LongStatus" truncates to 4 cols → "*Lon".
227        let l = frame(vec![vec![cell('a')]], "LongStatus");
228        let r = frame(vec![vec![cell('x')]], "R");
229        let m = compose_split(&l, &r, 4, 9, true); // lw=4, rw = 9-(4+1)=4
230        // Left status segment is exactly the first 4 cols of the row's status.
231        assert!(m.status.starts_with("*Lon"), "focused-left status truncated to width 4: {:?}", m.status);
232        // The divider separates the two pane statuses; left segment is 4 wide.
233        let div_pos = m.status.find('\u{2502}').expect("divider in status");
234        // 4 display columns before the divider (all ASCII here → 4 bytes).
235        assert_eq!(div_pos, 4, "left status occupies exactly left_w columns before divider");
236    }
237}