1use crate::line_index::LineIndex;
6use crate::render::Cell;
7use crate::source::Source;
8use crate::viewport::{Frame, RowStyle, Viewport};
9
10pub const DIVIDER: usize = 1;
12
13pub 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
25pub fn split_widths(cols: u16) -> (u16, u16) {
29 const MIN: usize = 8; 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}', width: 1,
43 style: crate::ansi::Style { dim: true, ..Default::default() },
44 hyperlink: None,
45 }
46}
47
48fn 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
58fn 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
79pub 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 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 let l = frame(vec![vec![cell('a')], vec![cell('b')]], "L"); let r = frame(vec![vec![cell('x')]], "R"); let m = compose_split(&l, &r, 1, 3, true); 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 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 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); assert!(m.status.starts_with("*Lon"), "focused-left status truncated to width 4: {:?}", m.status);
232 let div_pos = m.status.find('\u{2502}').expect("divider in status");
234 assert_eq!(div_pos, 4, "left status occupies exactly left_w columns before divider");
236 }
237}