Skip to main content

tui/rendering/
frame.rs

1use super::line::Line;
2use super::soft_wrap::{soft_wrap_lines_with_map, truncate_line};
3
4/// Logical cursor position within a component's rendered output.
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6pub struct Cursor {
7    pub row: usize,
8    pub col: usize,
9    pub is_visible: bool,
10}
11
12impl Cursor {
13    /// Create a hidden cursor at position (0, 0).
14    pub fn hidden() -> Self {
15        Self::default()
16    }
17
18    /// Create a visible cursor at the given position.
19    pub fn visible(row: usize, col: usize) -> Self {
20        Self { row, col, is_visible: true }
21    }
22
23    /// Shift a visible cursor right by `delta` columns. Hidden cursors are
24    /// returned unchanged.
25    pub fn shift_col(self, delta: usize) -> Self {
26        if self.is_visible { Self { col: self.col + delta, ..self } } else { self }
27    }
28}
29
30/// Overflow policy used by [`Frame::fit`] when content exceeds the target width.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Overflow {
33    /// Wrap rows that exceed the width onto additional visual rows.
34    Wrap,
35    /// Truncate rows that exceed the width. Row count is preserved.
36    Truncate,
37}
38
39/// Configuration for [`Frame::fit`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct FitOptions {
42    pub overflow_x: Overflow,
43    /// When true, every resulting row is padded to `width` so the row visually
44    /// fills its allocated box. Padding inherits any background color present
45    /// on the row.
46    pub fill_x: bool,
47}
48
49impl FitOptions {
50    /// Wrapping fit, no fill.
51    pub fn wrap() -> Self {
52        Self { overflow_x: Overflow::Wrap, fill_x: false }
53    }
54
55    /// Truncating fit, no fill.
56    pub fn truncate() -> Self {
57        Self { overflow_x: Overflow::Truncate, fill_x: false }
58    }
59
60    /// Builder: enable row fill.
61    pub fn with_fill(mut self) -> Self {
62        self.fill_x = true;
63        self
64    }
65}
66
67/// A horizontally-stacked slot in [`Frame::hstack`].
68///
69/// Each part holds a child frame and the width it occupies in the composed
70/// output. Use [`FramePart::fit`] / [`FramePart::wrap`] / [`FramePart::truncate`]
71/// to construct a slot from an unrestricted child frame — they fit the inner
72/// frame to `width` for you. Use [`FramePart::new`] only when the caller has
73/// already guaranteed the frame fits the slot (e.g., a separator column built
74/// at exactly the right width).
75#[derive(Debug, Clone)]
76pub struct FramePart {
77    pub frame: Frame,
78    pub width: u16,
79}
80
81impl FramePart {
82    /// Construct a slot from an already-fitted frame. Caller is responsible
83    /// for ensuring `frame` does not exceed `width`. Prefer `fit` / `wrap` /
84    /// `truncate` for unrestricted children.
85    pub fn new(frame: Frame, width: u16) -> Self {
86        Self { frame, width }
87    }
88
89    /// Fit `frame` to `width` with the given options before adopting it as a
90    /// slot. This is the right constructor for slots built from arbitrary
91    /// child output.
92    pub fn fit(frame: Frame, width: u16, options: FitOptions) -> Self {
93        Self { frame: frame.fit(width, options), width }
94    }
95
96    /// Shorthand for `FramePart::fit(frame, width, FitOptions::wrap().with_fill())`.
97    /// Soft-wraps the child to the slot width and marks each row to fill its
98    /// background, so the slot paints to the right edge.
99    pub fn wrap(frame: Frame, width: u16) -> Self {
100        Self::fit(frame, width, FitOptions::wrap().with_fill())
101    }
102
103    /// Shorthand for `FramePart::fit(frame, width, FitOptions::truncate().with_fill())`.
104    /// Truncates each row of the child to the slot width and marks each row
105    /// to fill its background.
106    pub fn truncate(frame: Frame, width: u16) -> Self {
107        Self::fit(frame, width, FitOptions::truncate().with_fill())
108    }
109}
110
111#[doc = include_str!("../docs/frame.md")]
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Frame {
114    lines: Vec<Line>,
115    cursor: Cursor,
116}
117
118impl Frame {
119    pub fn new(lines: Vec<Line>) -> Self {
120        Self { lines, cursor: Cursor::hidden() }
121    }
122
123    /// An empty frame with a hidden cursor.
124    pub fn empty() -> Self {
125        Self { lines: Vec::new(), cursor: Cursor::hidden() }
126    }
127
128    pub fn lines(&self) -> &[Line] {
129        &self.lines
130    }
131
132    pub fn cursor(&self) -> Cursor {
133        self.cursor
134    }
135
136    /// Replace the cursor without cloning lines.
137    pub fn with_cursor(mut self, cursor: Cursor) -> Self {
138        self.cursor = cursor;
139        self
140    }
141
142    pub fn into_lines(self) -> Vec<Line> {
143        self.lines
144    }
145
146    pub fn into_parts(self) -> (Vec<Line>, Cursor) {
147        (self.lines, self.cursor)
148    }
149
150    pub fn clamp_cursor(mut self) -> Self {
151        if self.cursor.row >= self.lines.len() {
152            self.cursor.row = self.lines.len().saturating_sub(1);
153        }
154        self
155    }
156
157    /// Fit the frame to a target width.
158    ///
159    /// - [`Overflow::Wrap`]: each line is soft-wrapped to `width`. The cursor
160    ///   row is remapped to the wrapped visual row, and the cursor column is
161    ///   reduced modulo `width`, with the row advanced for any overflow. This
162    ///   matches the wrap math used by `VisualFrame`.
163    /// - [`Overflow::Truncate`]: each line is truncated to `width`. Row count
164    ///   is unchanged. The cursor column is clamped to `width.saturating_sub(1)`.
165    /// - `fill_x`: every resulting row is marked with row-fill metadata using
166    ///   any background color present on the row. The fill is **not**
167    ///   materialized into trailing spaces here — that happens later, in
168    ///   [`Frame::hstack`] (per slot width) or
169    ///   `VisualFrame` (per terminal width).
170    ///   Deferring materialization is what prevents trailing-space rows from
171    ///   producing phantom rows when wrapped again at a smaller width.
172    ///
173    /// As a special case, `width == 0` returns the frame unchanged with a
174    /// hidden cursor (matching the zero-width behavior of `VisualFrame`).
175    pub fn fit(self, width: u16, options: FitOptions) -> Self {
176        if width == 0 {
177            return Self { lines: self.lines, cursor: Cursor::hidden() };
178        }
179
180        match options.overflow_x {
181            Overflow::Wrap => self.fit_wrap(width, options.fill_x),
182            Overflow::Truncate => self.fit_truncate(width, options.fill_x),
183        }
184    }
185
186    /// Shift visual content `cols` columns to the right by prepending spaces
187    /// to each line. The cursor column is shifted by `cols`. The row is
188    /// unchanged.
189    ///
190    /// The prepended prefix inherits any background color from the line, so
191    /// row-fill highlights extend through the indent.
192    pub fn indent(self, cols: u16) -> Self {
193        if cols == 0 {
194            return self;
195        }
196        let prefix = " ".repeat(usize::from(cols));
197        let lines = self.lines.into_iter().map(|line| line.prepend(prefix.clone())).collect();
198        Self { lines, cursor: self.cursor.shift_col(usize::from(cols)) }
199    }
200
201    /// Concatenate frames vertically.
202    ///
203    /// The first frame in the iterator that has a visible cursor wins. Its
204    /// row is offset by the cumulative line count of all preceding frames.
205    pub fn vstack(frames: impl IntoIterator<Item = Frame>) -> Self {
206        let mut all_lines: Vec<Line> = Vec::new();
207        let mut cursor = Cursor::hidden();
208        for frame in frames {
209            let row_offset = all_lines.len();
210            if !cursor.is_visible && frame.cursor.is_visible {
211                cursor = Cursor { row: frame.cursor.row + row_offset, col: frame.cursor.col, is_visible: true };
212            }
213            all_lines.extend(frame.lines);
214        }
215        Self { lines: all_lines, cursor }
216    }
217
218    /// Compose frames horizontally into fixed-width slots.
219    ///
220    /// Each part's frame is assumed to already fit its `width` (callers should
221    /// `fit(width, FitOptions::wrap())` or similar first). Any row-fill
222    /// metadata on a part's rows is materialized to the part's slot width
223    /// before merging, so trailing fill never bleeds into a neighboring slot.
224    /// Heights are balanced by padding shorter frames with blank rows of the
225    /// slot's width. The first part with a visible cursor wins; its column is
226    /// offset by the cumulative width of preceding slots.
227    pub fn hstack(parts: impl IntoIterator<Item = FramePart>) -> Self {
228        let parts: Vec<FramePart> = parts.into_iter().collect();
229        if parts.is_empty() {
230            return Self::empty();
231        }
232
233        let max_rows = parts.iter().map(|p| p.frame.lines.len()).max().unwrap_or(0);
234
235        let mut cursor = Cursor::hidden();
236        let mut col_offset: usize = 0;
237        for part in &parts {
238            if !cursor.is_visible && part.frame.cursor.is_visible {
239                cursor =
240                    Cursor { row: part.frame.cursor.row, col: part.frame.cursor.col + col_offset, is_visible: true };
241            }
242            col_offset += usize::from(part.width);
243        }
244
245        let mut merged: Vec<Line> = Vec::with_capacity(max_rows);
246        for row_idx in 0..max_rows {
247            let mut row = Line::default();
248            for part in &parts {
249                let slot_width = usize::from(part.width);
250                let Some(line) = part.frame.lines.get(row_idx) else {
251                    row.push_text(" ".repeat(slot_width));
252                    continue;
253                };
254                if line.fill().is_some() {
255                    let mut materialized = line.clone();
256                    materialized.extend_bg_to_width(slot_width);
257                    row.append_line(&materialized);
258                } else {
259                    row.append_line(line);
260                    let line_width = line.display_width();
261                    if line_width < slot_width {
262                        row.push_text(" ".repeat(slot_width - line_width));
263                    }
264                }
265            }
266            merged.push(row);
267        }
268
269        Self { lines: merged, cursor }
270    }
271
272    /// Apply `f` to each line in turn, preserving cursor and overall row
273    /// count. The function may not split or merge rows; doing so will leave
274    /// the cursor pointing at the wrong row.
275    pub fn map_lines<T: FnMut(Line) -> Line>(self, f: T) -> Self {
276        let lines = self.lines.into_iter().map(f).collect();
277        Self { lines, cursor: self.cursor }
278    }
279
280    /// Prepend a fixed-width gutter to each row. The first row gets `head`,
281    /// subsequent rows get `tail`. Use the same value for both for a uniform
282    /// gutter, or different values for first/continuation patterns (e.g.,
283    /// line numbers on the first row of a wrapped block, blanks on the rest).
284    ///
285    /// `head` and `tail` must have equal display width — debug-asserted. The
286    /// cursor column is shifted by that width. Any row-fill metadata on the
287    /// original row is preserved on the prefixed row.
288    pub fn prefix(self, head: &Line, tail: &Line) -> Self {
289        let shift = head.display_width();
290        debug_assert_eq!(shift, tail.display_width(), "Frame::prefix: head and tail must have equal display width");
291        let lines: Vec<Line> = self
292            .lines
293            .into_iter()
294            .enumerate()
295            .map(|(i, line)| {
296                let prefix_src = if i == 0 { head } else { tail };
297                let row_fill = line.fill();
298                let mut prefixed = Line::default();
299                prefixed.append_line(prefix_src);
300                prefixed.append_line(&line);
301                prefixed.set_fill(row_fill);
302                prefixed
303            })
304            .collect();
305
306        Self { lines, cursor: self.cursor.shift_col(shift) }
307    }
308
309    /// Pad with blank rows of `width` columns until at least `target` rows
310    /// total. No-op if already at or above `target`. Cursor preserved.
311    pub fn pad_height(self, target: u16, width: u16) -> Self {
312        let target_usize = usize::from(target);
313        let mut lines = self.lines;
314        if lines.len() < target_usize {
315            let blank = Line::new(" ".repeat(usize::from(width)));
316            lines.resize(target_usize, blank);
317        }
318        Self { lines, cursor: self.cursor }
319    }
320
321    /// Truncate to at most `target` rows. If the visible cursor falls beyond
322    /// the truncation, it is hidden.
323    pub fn truncate_height(self, target: u16) -> Self {
324        let target_usize = usize::from(target);
325        let mut lines = self.lines;
326        if lines.len() > target_usize {
327            lines.truncate(target_usize);
328        }
329        let cursor =
330            if self.cursor.is_visible && self.cursor.row >= target_usize { Cursor::hidden() } else { self.cursor };
331        Self { lines, cursor }
332    }
333
334    /// Force the frame to exactly `target` rows: truncate if taller, pad with
335    /// blank rows of `width` columns if shorter. Convenience for layouts that
336    /// emit a fixed-height region regardless of child content.
337    pub fn fit_height(self, target: u16, width: u16) -> Self {
338        self.truncate_height(target).pad_height(target, width)
339    }
340
341    /// Wrap each row in side chrome: materialize fill to `inner_width`, then
342    /// prepend `left` and append `right` to every row. The cursor column is
343    /// shifted by `left.display_width()`.
344    ///
345    /// Used for borders/box chrome where the row's interior should fill its
346    /// allocated width before the right edge is appended.
347    pub fn wrap_each(self, inner_width: u16, left: &Line, right: &Line) -> Self {
348        let inner_width_usize = usize::from(inner_width);
349        let left_width = left.display_width();
350        let lines: Vec<Line> = self
351            .lines
352            .into_iter()
353            .map(|mut line| {
354                line.extend_bg_to_width(inner_width_usize);
355                let mut wrapped = Line::default();
356                wrapped.append_line(left);
357                wrapped.append_line(&line);
358                wrapped.append_line(right);
359                wrapped
360            })
361            .collect();
362        Self { lines, cursor: self.cursor.shift_col(left_width) }
363    }
364
365    /// Insert the lines of `other` into this frame immediately after `after_row`.
366    ///
367    /// Rows `0..=after_row` remain in place. The lines of `other` appear
368    /// between row `after_row` and what was previously row `after_row + 1`.
369    /// Rows that followed the insertion point shift down by
370    /// `other.lines().len()`.
371    ///
372    /// If `after_row >= self.lines.len()`, the lines are appended at the end.
373    ///
374    /// Cursor rule (consistent with `vstack` / `hstack`): the host frame's
375    /// visible cursor takes priority — shifted down when it sits after the
376    /// insertion point. Otherwise, `other`'s visible cursor is adopted with
377    /// its row offset to the insertion position.
378    pub fn splice(self, after_row: usize, other: Frame) -> Self {
379        let inserted_count = other.lines.len();
380        if inserted_count == 0 {
381            return self;
382        }
383
384        let split_at = (after_row + 1).min(self.lines.len());
385        let mut lines = self.lines;
386        let tail: Vec<Line> = lines.drain(split_at..).collect();
387        lines.extend(other.lines);
388        lines.extend(tail);
389
390        let cursor = if self.cursor.is_visible {
391            if self.cursor.row > after_row {
392                Cursor { row: self.cursor.row + inserted_count, ..self.cursor }
393            } else {
394                self.cursor
395            }
396        } else if other.cursor.is_visible {
397            Cursor { row: other.cursor.row + split_at, col: other.cursor.col, is_visible: true }
398        } else {
399            Cursor::hidden()
400        };
401
402        Self { lines, cursor }
403    }
404
405    /// Drop the first `offset` rows and keep at most `height` rows.
406    ///
407    /// The cursor row is shifted up by `offset`. If the cursor falls outside
408    /// the visible window it is hidden.
409    pub fn scroll(self, offset: usize, height: usize) -> Self {
410        let end = (offset + height).min(self.lines.len());
411        let visible: Vec<Line> = self.lines.into_iter().skip(offset).take(height).collect();
412
413        let cursor = if self.cursor.is_visible && self.cursor.row >= offset && self.cursor.row < end {
414            Cursor { row: self.cursor.row - offset, col: self.cursor.col, is_visible: true }
415        } else {
416            Cursor::hidden()
417        };
418
419        Self { lines: visible, cursor }
420    }
421
422    fn fit_wrap(self, width: u16, fill_x: bool) -> Self {
423        let (mut wrapped_lines, logical_to_visual) = soft_wrap_lines_with_map(&self.lines, width);
424
425        let cursor = if self.cursor.is_visible {
426            let mut visual_row = logical_to_visual
427                .get(self.cursor.row)
428                .copied()
429                .unwrap_or_else(|| wrapped_lines.len().saturating_sub(1));
430            let mut visual_col = self.cursor.col;
431            let width_usize = usize::from(width);
432            visual_row += visual_col / width_usize;
433            visual_col %= width_usize;
434            if visual_row >= wrapped_lines.len() {
435                visual_row = wrapped_lines.len().saturating_sub(1);
436            }
437            Cursor { row: visual_row, col: visual_col, is_visible: true }
438        } else {
439            Cursor::hidden()
440        };
441
442        apply_fill_metadata(&mut wrapped_lines, fill_x);
443        Self { lines: wrapped_lines, cursor }
444    }
445
446    fn fit_truncate(self, width: u16, fill_x: bool) -> Self {
447        let width_usize = usize::from(width);
448        let mut lines: Vec<Line> = self.lines.iter().map(|line| truncate_line(line, width_usize)).collect();
449
450        apply_fill_metadata(&mut lines, fill_x);
451
452        let cursor = if self.cursor.is_visible {
453            let max_col = width_usize.saturating_sub(1);
454            Cursor { row: self.cursor.row, col: self.cursor.col.min(max_col), is_visible: true }
455        } else {
456            Cursor::hidden()
457        };
458
459        Self { lines, cursor }
460    }
461}
462
463fn apply_fill_metadata(lines: &mut [Line], fill_x: bool) {
464    if !fill_x {
465        return;
466    }
467    for line in lines {
468        line.set_fill(line.infer_fill_color());
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn cursor_hidden_returns_invisible_cursor_at_origin() {
478        let cursor = Cursor::hidden();
479        assert_eq!(cursor.row, 0);
480        assert_eq!(cursor.col, 0);
481        assert!(!cursor.is_visible);
482    }
483
484    #[test]
485    fn cursor_visible_returns_visible_cursor_at_position() {
486        let cursor = Cursor::visible(5, 10);
487        assert_eq!(cursor.row, 5);
488        assert_eq!(cursor.col, 10);
489        assert!(cursor.is_visible);
490    }
491
492    #[test]
493    fn clamp_cursor_clamps_out_of_bounds_row() {
494        let frame = Frame::new(vec![Line::new("a")]).with_cursor(Cursor::visible(10, 100));
495        let frame = frame.clamp_cursor();
496        assert_eq!(frame.cursor().row, 0);
497        assert_eq!(frame.cursor().col, 100);
498    }
499
500    #[test]
501    fn with_cursor_replaces_cursor_without_cloning_lines() {
502        let frame = Frame::new(vec![Line::new("hello")]);
503        let new_cursor = Cursor::visible(0, 3);
504        let frame = frame.with_cursor(new_cursor);
505        assert_eq!(frame.cursor(), new_cursor);
506        assert_eq!(frame.lines()[0].plain_text(), "hello");
507    }
508
509    #[test]
510    fn fit_wrap_breaks_long_line_into_multiple_rows() {
511        let frame = Frame::new(vec![Line::new("abcdef")]);
512        let frame = frame.fit(3, FitOptions::wrap());
513        assert_eq!(frame.lines().len(), 2);
514        assert_eq!(frame.lines()[0].plain_text(), "abc");
515        assert_eq!(frame.lines()[1].plain_text(), "def");
516    }
517
518    #[test]
519    fn fit_wrap_remaps_cursor_on_wrapped_row() {
520        let frame = Frame::new(vec![Line::new("abcdef")]).with_cursor(Cursor::visible(0, 5));
521        let frame = frame.fit(3, FitOptions::wrap());
522        // col 5 → row += 5/3 = 1, col = 5%3 = 2
523        assert_eq!(frame.cursor().row, 1);
524        assert_eq!(frame.cursor().col, 2);
525        assert!(frame.cursor().is_visible);
526    }
527
528    #[test]
529    fn fit_wrap_remaps_cursor_across_logical_rows() {
530        let frame = Frame::new(vec![Line::new("abcdef"), Line::new("xy")]).with_cursor(Cursor::visible(1, 1));
531        let frame = frame.fit(3, FitOptions::wrap());
532        // logical row 0 wraps to 2 visual rows ("abc","def"), logical row 1 starts at visual row 2.
533        // Cursor at logical (1, 1) → visual (2, 1).
534        assert_eq!(frame.cursor().row, 2);
535        assert_eq!(frame.cursor().col, 1);
536    }
537
538    #[test]
539    fn fit_wrap_hides_cursor_when_logical_row_is_invisible() {
540        let frame = Frame::new(vec![Line::new("abcdef")]);
541        let frame = frame.fit(3, FitOptions::wrap());
542        assert!(!frame.cursor().is_visible);
543    }
544
545    #[test]
546    fn fit_wrap_with_fill_marks_each_row_with_fill_metadata_only() {
547        use crate::style::Style;
548        use crossterm::style::Color;
549        // fit(...with_fill()) defers materialization. Each wrapped row has no
550        // trailing spaces yet — the fill metadata is set so that hstack /
551        // VisualFrame can materialize against the appropriate target width.
552        let frame = Frame::new(vec![Line::with_style("abcdef", Style::default().bg_color(Color::Blue))]);
553        let frame = frame.fit(4, FitOptions::wrap().with_fill());
554        assert_eq!(frame.lines().len(), 2);
555        assert_eq!(frame.lines()[0].plain_text(), "abcd");
556        assert_eq!(frame.lines()[1].plain_text(), "ef");
557        for line in frame.lines() {
558            assert_eq!(line.fill(), Some(Color::Blue), "fill metadata should be set");
559        }
560    }
561
562    #[test]
563    fn fit_wrap_zero_width_returns_lines_unchanged_and_hides_cursor() {
564        let frame = Frame::new(vec![Line::new("abc")]).with_cursor(Cursor::visible(0, 1));
565        let frame = frame.fit(0, FitOptions::wrap());
566        assert_eq!(frame.lines().len(), 1);
567        assert_eq!(frame.lines()[0].plain_text(), "abc");
568        assert!(!frame.cursor().is_visible);
569    }
570
571    #[test]
572    fn fit_truncate_cuts_each_row_to_width() {
573        let frame = Frame::new(vec![Line::new("abcdef"), Line::new("xyz")]);
574        let frame = frame.fit(3, FitOptions::truncate());
575        assert_eq!(frame.lines().len(), 2);
576        assert_eq!(frame.lines()[0].plain_text(), "abc");
577        assert_eq!(frame.lines()[1].plain_text(), "xyz");
578    }
579
580    #[test]
581    fn fit_truncate_clamps_cursor_col_within_width() {
582        let frame = Frame::new(vec![Line::new("abcdef")]).with_cursor(Cursor::visible(0, 10));
583        let frame = frame.fit(3, FitOptions::truncate());
584        assert_eq!(frame.cursor().row, 0);
585        assert_eq!(frame.cursor().col, 2); // width - 1
586        assert!(frame.cursor().is_visible);
587    }
588
589    #[test]
590    fn fit_truncate_preserves_in_range_cursor() {
591        let frame = Frame::new(vec![Line::new("abcdef")]).with_cursor(Cursor::visible(0, 1));
592        let frame = frame.fit(5, FitOptions::truncate());
593        assert_eq!(frame.cursor().col, 1);
594    }
595
596    #[test]
597    fn fit_truncate_preserves_row_count() {
598        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
599        let frame = frame.fit(2, FitOptions::truncate());
600        assert_eq!(frame.lines().len(), 3);
601    }
602
603    #[test]
604    fn fit_truncate_with_fill_marks_rows_with_fill_metadata_only() {
605        use crate::style::Style;
606        use crossterm::style::Color;
607        let frame = Frame::new(vec![Line::with_style("ab", Style::default().bg_color(Color::Red))]);
608        let frame = frame.fit(5, FitOptions::truncate().with_fill());
609        // No materialization here — content is unchanged but fill is set.
610        assert_eq!(frame.lines()[0].plain_text(), "ab");
611        assert_eq!(frame.lines()[0].fill(), Some(Color::Red));
612    }
613
614    #[test]
615    fn indent_prepends_spaces_to_each_line() {
616        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]);
617        let frame = frame.indent(2);
618        assert_eq!(frame.lines()[0].plain_text(), "  a");
619        assert_eq!(frame.lines()[1].plain_text(), "  b");
620    }
621
622    #[test]
623    fn indent_shifts_cursor_col() {
624        let frame = Frame::new(vec![Line::new("hi")]).with_cursor(Cursor::visible(0, 1));
625        let frame = frame.indent(3);
626        assert_eq!(frame.cursor().row, 0);
627        assert_eq!(frame.cursor().col, 4);
628        assert!(frame.cursor().is_visible);
629    }
630
631    #[test]
632    fn indent_zero_is_noop() {
633        let frame = Frame::new(vec![Line::new("hi")]).with_cursor(Cursor::visible(0, 1));
634        let original_text = frame.lines()[0].plain_text();
635        let original_cursor = frame.cursor();
636        let frame = frame.indent(0);
637        assert_eq!(frame.lines()[0].plain_text(), original_text);
638        assert_eq!(frame.cursor(), original_cursor);
639    }
640
641    #[test]
642    fn indent_carries_background_through_prefix() {
643        use crate::style::Style;
644        use crossterm::style::Color;
645        let frame = Frame::new(vec![Line::with_style("hi", Style::default().bg_color(Color::Blue))]);
646        let frame = frame.indent(2);
647        let line = &frame.lines()[0];
648        // Prefix span should carry the background color from the original line.
649        assert_eq!(line.spans()[0].style().bg, Some(Color::Blue));
650        assert_eq!(line.plain_text(), "  hi");
651    }
652
653    #[test]
654    fn indent_does_not_make_hidden_cursor_visible() {
655        let frame = Frame::new(vec![Line::new("hi")]);
656        let frame = frame.indent(2);
657        assert!(!frame.cursor().is_visible);
658    }
659
660    #[test]
661    fn vstack_empty_input_produces_empty_frame() {
662        let frame = Frame::vstack(std::iter::empty());
663        assert!(frame.lines().is_empty());
664        assert!(!frame.cursor().is_visible);
665    }
666
667    #[test]
668    fn vstack_concatenates_in_order() {
669        let a = Frame::new(vec![Line::new("a1"), Line::new("a2")]);
670        let b = Frame::new(vec![Line::new("b1")]);
671        let frame = Frame::vstack([a, b]);
672        assert_eq!(frame.lines().len(), 3);
673        assert_eq!(frame.lines()[0].plain_text(), "a1");
674        assert_eq!(frame.lines()[1].plain_text(), "a2");
675        assert_eq!(frame.lines()[2].plain_text(), "b1");
676    }
677
678    #[test]
679    fn vstack_offsets_cursor_by_preceding_line_count() {
680        let a = Frame::new(vec![Line::new("a1"), Line::new("a2")]);
681        let b = Frame::new(vec![Line::new("b1")]).with_cursor(Cursor::visible(0, 0));
682        let frame = Frame::vstack([a, b]);
683        assert_eq!(frame.cursor().row, 2);
684        assert_eq!(frame.cursor().col, 0);
685        assert!(frame.cursor().is_visible);
686    }
687
688    #[test]
689    fn vstack_first_visible_cursor_wins() {
690        let a = Frame::new(vec![Line::new("a")]).with_cursor(Cursor::visible(0, 1));
691        let b = Frame::new(vec![Line::new("b")]).with_cursor(Cursor::visible(0, 5));
692        let frame = Frame::vstack([a, b]);
693        assert_eq!(frame.cursor().row, 0);
694        assert_eq!(frame.cursor().col, 1);
695    }
696
697    #[test]
698    fn vstack_no_visible_cursor_returns_hidden_cursor() {
699        let a = Frame::new(vec![Line::new("a")]);
700        let b = Frame::new(vec![Line::new("b")]);
701        let frame = Frame::vstack([a, b]);
702        assert!(!frame.cursor().is_visible);
703    }
704
705    #[test]
706    fn hstack_empty_input_produces_empty_frame() {
707        let frame = Frame::hstack(std::iter::empty());
708        assert!(frame.lines().is_empty());
709        assert!(!frame.cursor().is_visible);
710    }
711
712    #[test]
713    fn hstack_merges_equal_height_parts_row_by_row() {
714        let left = Frame::new(vec![Line::new("aa"), Line::new("bb")]);
715        let right = Frame::new(vec![Line::new("XX"), Line::new("YY")]);
716        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(right, 2)]);
717        assert_eq!(frame.lines().len(), 2);
718        assert_eq!(frame.lines()[0].plain_text(), "aaXX");
719        assert_eq!(frame.lines()[1].plain_text(), "bbYY");
720    }
721
722    #[test]
723    fn hstack_pads_shorter_part_with_blank_rows() {
724        let left = Frame::new(vec![Line::new("aa"), Line::new("bb")]);
725        let right = Frame::new(vec![Line::new("XX")]);
726        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(right, 2)]);
727        assert_eq!(frame.lines().len(), 2);
728        assert_eq!(frame.lines()[0].plain_text(), "aaXX");
729        assert_eq!(frame.lines()[1].plain_text(), "bb  ");
730    }
731
732    #[test]
733    fn hstack_left_visible_cursor_unchanged_col() {
734        let left = Frame::new(vec![Line::new("aa")]).with_cursor(Cursor::visible(0, 1));
735        let right = Frame::new(vec![Line::new("XX")]);
736        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(right, 2)]);
737        assert_eq!(frame.cursor().row, 0);
738        assert_eq!(frame.cursor().col, 1);
739        assert!(frame.cursor().is_visible);
740    }
741
742    #[test]
743    fn hstack_right_visible_cursor_offset_by_left_width() {
744        let left = Frame::new(vec![Line::new("aaa")]);
745        let right = Frame::new(vec![Line::new("XX")]).with_cursor(Cursor::visible(0, 1));
746        let frame = Frame::hstack([FramePart::new(left, 3), FramePart::new(right, 2)]);
747        assert_eq!(frame.cursor().row, 0);
748        assert_eq!(frame.cursor().col, 1 + 3);
749        assert!(frame.cursor().is_visible);
750    }
751
752    #[test]
753    fn hstack_first_visible_cursor_wins_when_both_present() {
754        let left = Frame::new(vec![Line::new("aa")]).with_cursor(Cursor::visible(0, 0));
755        let right = Frame::new(vec![Line::new("XX")]).with_cursor(Cursor::visible(0, 1));
756        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(right, 2)]);
757        assert_eq!(frame.cursor().col, 0);
758    }
759
760    #[test]
761    fn hstack_no_visible_cursor_returns_hidden_cursor() {
762        let left = Frame::new(vec![Line::new("aa")]);
763        let right = Frame::new(vec![Line::new("XX")]);
764        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(right, 2)]);
765        assert!(!frame.cursor().is_visible);
766    }
767
768    #[test]
769    fn hstack_materializes_fill_to_each_part_slot_width() {
770        use crate::style::Style;
771        use crossterm::style::Color;
772
773        let left =
774            Frame::new(vec![Line::with_style("hi", Style::default().bg_color(Color::Red)).with_fill(Color::Red)]);
775        let right = Frame::new(vec![Line::new("XX")]);
776        let frame = Frame::hstack([FramePart::new(left, 5), FramePart::new(right, 2)]);
777        // Left slot should be expanded to width 5 with red fill, then "XX" appended.
778        assert_eq!(frame.lines()[0].plain_text(), "hi   XX");
779        // Materialized; no fill metadata leaks through.
780        assert_eq!(frame.lines()[0].fill(), None);
781    }
782
783    #[test]
784    fn fit_wrap_with_fill_propagates_metadata_to_wrapped_rows() {
785        use crate::style::Style;
786        use crossterm::style::Color;
787
788        let line = Line::with_style("abcdefgh", Style::default().bg_color(Color::Blue));
789        let frame = Frame::new(vec![line]).fit(3, FitOptions::wrap().with_fill());
790        assert_eq!(frame.lines().len(), 3);
791        for row in frame.lines() {
792            assert_eq!(row.fill(), Some(Color::Blue), "every wrapped row should carry fill metadata");
793        }
794    }
795
796    #[test]
797    fn hstack_three_parts_offsets_cursor_by_cumulative_widths() {
798        let left = Frame::new(vec![Line::new("aa")]);
799        let mid = Frame::new(vec![Line::new("|")]);
800        let right = Frame::new(vec![Line::new("XX")]).with_cursor(Cursor::visible(0, 1));
801        let frame = Frame::hstack([FramePart::new(left, 2), FramePart::new(mid, 1), FramePart::new(right, 2)]);
802        assert_eq!(frame.lines()[0].plain_text(), "aa|XX");
803        assert_eq!(frame.cursor().col, 1 + 2 + 1);
804    }
805
806    #[test]
807    fn map_lines_applies_function_to_each_line() {
808        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]);
809        let frame = frame.map_lines(|mut line| {
810            line.push_text("!");
811            line
812        });
813        assert_eq!(frame.lines()[0].plain_text(), "a!");
814        assert_eq!(frame.lines()[1].plain_text(), "b!");
815    }
816
817    #[test]
818    fn map_lines_preserves_cursor() {
819        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]).with_cursor(Cursor::visible(1, 0));
820        let frame = frame.map_lines(|line| line);
821        assert_eq!(frame.cursor(), Cursor::visible(1, 0));
822    }
823
824    #[test]
825    fn map_lines_preserves_row_count() {
826        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
827        let frame = frame.map_lines(|line| line);
828        assert_eq!(frame.lines().len(), 3);
829    }
830
831    #[test]
832    fn prefix_uses_head_on_first_row_and_tail_on_rest() {
833        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
834        let frame = frame.prefix(&Line::new("> "), &Line::new("  "));
835        assert_eq!(frame.lines()[0].plain_text(), "> a");
836        assert_eq!(frame.lines()[1].plain_text(), "  b");
837        assert_eq!(frame.lines()[2].plain_text(), "  c");
838    }
839
840    #[test]
841    fn prefix_shifts_cursor_col_by_gutter_width() {
842        let frame = Frame::new(vec![Line::new("hi")]).with_cursor(Cursor::visible(0, 1));
843        let frame = frame.prefix(&Line::new("> "), &Line::new("  "));
844        assert_eq!(frame.cursor().row, 0);
845        assert_eq!(frame.cursor().col, 1 + 2);
846        assert!(frame.cursor().is_visible);
847    }
848
849    #[test]
850    fn prefix_does_not_make_hidden_cursor_visible() {
851        let frame = Frame::new(vec![Line::new("a")]);
852        let frame = frame.prefix(&Line::new("> "), &Line::new("  "));
853        assert!(!frame.cursor().is_visible);
854    }
855
856    #[test]
857    fn prefix_preserves_row_fill_metadata() {
858        use crossterm::style::Color;
859        let line = Line::new("hi").with_fill(Color::Blue);
860        let frame = Frame::new(vec![line]);
861        let frame = frame.prefix(&Line::new("> "), &Line::new("  "));
862        assert_eq!(frame.lines()[0].fill(), Some(Color::Blue), "row-fill metadata should pass through prefix");
863    }
864
865    #[test]
866    fn prefix_carries_styled_head_into_output() {
867        use crate::style::Style;
868        use crossterm::style::Color;
869        let head = Line::with_style("├─ ", Style::fg(Color::Yellow));
870        let tail = Line::with_style("   ", Style::fg(Color::Yellow));
871        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]).prefix(&head, &tail);
872        assert_eq!(frame.lines()[0].spans()[0].style().fg, Some(Color::Yellow));
873        assert_eq!(frame.lines()[1].spans()[0].style().fg, Some(Color::Yellow));
874    }
875
876    #[test]
877    fn prefix_empty_frame_returns_empty() {
878        let frame = Frame::empty().prefix(&Line::new("> "), &Line::new("  "));
879        assert!(frame.lines().is_empty());
880        assert!(!frame.cursor().is_visible);
881    }
882
883    #[test]
884    fn pad_height_appends_blank_rows_to_reach_target() {
885        let frame = Frame::new(vec![Line::new("a")]);
886        let frame = frame.pad_height(3, 4);
887        assert_eq!(frame.lines().len(), 3);
888        assert_eq!(frame.lines()[0].plain_text(), "a");
889        assert_eq!(frame.lines()[1].plain_text(), "    ");
890        assert_eq!(frame.lines()[2].plain_text(), "    ");
891    }
892
893    #[test]
894    fn pad_height_no_op_if_already_at_or_above_target() {
895        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
896        let frame = frame.pad_height(2, 4);
897        assert_eq!(frame.lines().len(), 3);
898    }
899
900    #[test]
901    fn pad_height_preserves_cursor() {
902        let frame = Frame::new(vec![Line::new("a")]).with_cursor(Cursor::visible(0, 1));
903        let frame = frame.pad_height(3, 4);
904        assert_eq!(frame.cursor(), Cursor::visible(0, 1));
905    }
906
907    #[test]
908    fn truncate_height_drops_excess_rows() {
909        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c"), Line::new("d")]);
910        let frame = frame.truncate_height(2);
911        assert_eq!(frame.lines().len(), 2);
912        assert_eq!(frame.lines()[0].plain_text(), "a");
913        assert_eq!(frame.lines()[1].plain_text(), "b");
914    }
915
916    #[test]
917    fn truncate_height_hides_cursor_when_row_falls_outside() {
918        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(2, 0));
919        let frame = frame.truncate_height(2);
920        assert!(!frame.cursor().is_visible);
921    }
922
923    #[test]
924    fn truncate_height_preserves_cursor_when_in_range() {
925        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(1, 0));
926        let frame = frame.truncate_height(2);
927        assert_eq!(frame.cursor(), Cursor::visible(1, 0));
928    }
929
930    #[test]
931    fn truncate_height_no_op_if_already_below_target() {
932        let frame = Frame::new(vec![Line::new("a")]);
933        let frame = frame.truncate_height(5);
934        assert_eq!(frame.lines().len(), 1);
935    }
936
937    #[test]
938    fn fit_height_truncates_taller_frames() {
939        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c"), Line::new("d")]);
940        let frame = frame.fit_height(2, 4);
941        assert_eq!(frame.lines().len(), 2);
942    }
943
944    #[test]
945    fn fit_height_pads_shorter_frames() {
946        let frame = Frame::new(vec![Line::new("a")]);
947        let frame = frame.fit_height(3, 4);
948        assert_eq!(frame.lines().len(), 3);
949        assert_eq!(frame.lines()[1].plain_text(), "    ");
950    }
951
952    #[test]
953    fn wrap_each_adds_left_and_right_to_each_row() {
954        let frame = Frame::new(vec![Line::new("a"), Line::new("bb")]);
955        let frame = frame.wrap_each(3, &Line::new("│ "), &Line::new(" │"));
956        assert_eq!(frame.lines()[0].plain_text(), "│ a   │");
957        assert_eq!(frame.lines()[1].plain_text(), "│ bb  │");
958    }
959
960    #[test]
961    fn wrap_each_shifts_cursor_col_by_left_width() {
962        let frame = Frame::new(vec![Line::new("hi")]).with_cursor(Cursor::visible(0, 1));
963        let frame = frame.wrap_each(4, &Line::new("│ "), &Line::new(" │"));
964        assert_eq!(frame.cursor().row, 0);
965        assert_eq!(frame.cursor().col, 1 + 2);
966        assert!(frame.cursor().is_visible);
967    }
968
969    #[test]
970    fn wrap_each_materializes_fill_before_right_edge() {
971        use crate::style::Style;
972        use crossterm::style::Color;
973        let line = Line::with_style("hi", Style::default().bg_color(Color::Blue)).with_fill(Color::Blue);
974        let frame = Frame::new(vec![line]);
975        let frame = frame.wrap_each(5, &Line::new("│ "), &Line::new(" │"));
976        // Inner is padded to 5 cols ("hi   "), then borders surround it.
977        assert_eq!(frame.lines()[0].plain_text(), "│ hi    │");
978    }
979
980    #[test]
981    fn wrap_each_does_not_make_hidden_cursor_visible() {
982        let frame = Frame::new(vec![Line::new("a")]);
983        let frame = frame.wrap_each(3, &Line::new("│ "), &Line::new(" │"));
984        assert!(!frame.cursor().is_visible);
985    }
986
987    #[test]
988    fn frame_part_fit_wraps_inner_to_slot_width() {
989        let inner = Frame::new(vec![Line::new("abcdefgh")]);
990        let part = FramePart::fit(inner, 3, FitOptions::wrap());
991        assert_eq!(part.width, 3);
992        assert_eq!(part.frame.lines().len(), 3);
993        assert_eq!(part.frame.lines()[0].plain_text(), "abc");
994    }
995
996    #[test]
997    fn frame_part_wrap_marks_rows_with_fill_metadata_when_bg_present() {
998        use crate::style::Style;
999        use crossterm::style::Color;
1000        let inner = Frame::new(vec![Line::with_style("abcdefgh", Style::default().bg_color(Color::Red))]);
1001        let part = FramePart::wrap(inner, 3);
1002        for line in part.frame.lines() {
1003            assert_eq!(line.fill(), Some(Color::Red), "wrap should mark each wrapped row with fill metadata");
1004        }
1005    }
1006
1007    #[test]
1008    fn frame_part_truncate_clips_inner_to_slot_width() {
1009        let inner = Frame::new(vec![Line::new("abcdefgh"), Line::new("xy")]);
1010        let part = FramePart::truncate(inner, 3);
1011        assert_eq!(part.width, 3);
1012        assert_eq!(part.frame.lines().len(), 2);
1013        assert_eq!(part.frame.lines()[0].plain_text(), "abc");
1014        assert_eq!(part.frame.lines()[1].plain_text(), "xy");
1015    }
1016
1017    #[test]
1018    fn frame_part_truncate_marks_rows_with_fill_metadata_when_bg_present() {
1019        use crate::style::Style;
1020        use crossterm::style::Color;
1021        let inner = Frame::new(vec![Line::with_style("abc", Style::default().bg_color(Color::Green))]);
1022        let part = FramePart::truncate(inner, 5);
1023        assert_eq!(part.frame.lines()[0].fill(), Some(Color::Green));
1024    }
1025
1026    #[test]
1027    fn frame_part_wrap_then_hstack_composes_full_width_per_row() {
1028        let left = Frame::new(vec![Line::new("abcdefgh")]);
1029        let right = Frame::new(vec![Line::new("XX"), Line::new("YY"), Line::new("ZZ")]);
1030        let frame = Frame::hstack([FramePart::wrap(left, 3), FramePart::wrap(right, 2)]);
1031        assert_eq!(frame.lines().len(), 3);
1032        for line in frame.lines() {
1033            assert_eq!(line.display_width(), 5, "every composed row should be exactly slot_left + slot_right wide");
1034        }
1035        assert_eq!(frame.lines()[0].plain_text(), "abcXX");
1036        assert_eq!(frame.lines()[1].plain_text(), "defYY");
1037        assert_eq!(frame.lines()[2].plain_text(), "gh ZZ");
1038    }
1039
1040    #[test]
1041    fn splice_inserts_after_row() {
1042        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
1043        let other = Frame::new(vec![Line::new("X"), Line::new("Y")]);
1044        let frame = frame.splice(1, other);
1045        assert_eq!(frame.lines().len(), 5);
1046        assert_eq!(frame.lines()[0].plain_text(), "a");
1047        assert_eq!(frame.lines()[1].plain_text(), "b");
1048        assert_eq!(frame.lines()[2].plain_text(), "X");
1049        assert_eq!(frame.lines()[3].plain_text(), "Y");
1050        assert_eq!(frame.lines()[4].plain_text(), "c");
1051    }
1052
1053    #[test]
1054    fn splice_at_end_appends() {
1055        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]);
1056        let other = Frame::new(vec![Line::new("X")]);
1057        let frame = frame.splice(1, other);
1058        assert_eq!(frame.lines().len(), 3);
1059        assert_eq!(frame.lines()[2].plain_text(), "X");
1060    }
1061
1062    #[test]
1063    fn splice_beyond_end_appends() {
1064        let frame = Frame::new(vec![Line::new("a")]);
1065        let other = Frame::new(vec![Line::new("X")]);
1066        let frame = frame.splice(100, other);
1067        assert_eq!(frame.lines().len(), 2);
1068        assert_eq!(frame.lines()[1].plain_text(), "X");
1069    }
1070
1071    #[test]
1072    fn splice_empty_other_is_noop() {
1073        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]).with_cursor(Cursor::visible(1, 0));
1074        let other = Frame::empty();
1075        let frame = frame.splice(0, other);
1076        assert_eq!(frame.lines().len(), 2);
1077        assert_eq!(frame.cursor(), Cursor::visible(1, 0));
1078    }
1079
1080    #[test]
1081    fn splice_shifts_self_cursor_down() {
1082        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(2, 3));
1083        let other = Frame::new(vec![Line::new("X"), Line::new("Y"), Line::new("Z")]);
1084        let frame = frame.splice(1, other);
1085        assert_eq!(frame.cursor(), Cursor::visible(5, 3));
1086    }
1087
1088    #[test]
1089    fn splice_preserves_self_cursor_before_insertion() {
1090        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(0, 1));
1091        let other = Frame::new(vec![Line::new("X")]);
1092        let frame = frame.splice(1, other);
1093        assert_eq!(frame.cursor(), Cursor::visible(0, 1));
1094    }
1095
1096    #[test]
1097    fn splice_does_not_shift_self_cursor_on_insertion_row() {
1098        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(1, 0));
1099        let other = Frame::new(vec![Line::new("X")]);
1100        let frame = frame.splice(1, other);
1101        assert_eq!(frame.cursor(), Cursor::visible(1, 0));
1102    }
1103
1104    #[test]
1105    fn splice_adopts_other_cursor() {
1106        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
1107        let other = Frame::new(vec![Line::new("X"), Line::new("Y")]).with_cursor(Cursor::visible(1, 5));
1108        let frame = frame.splice(1, other);
1109        assert_eq!(frame.cursor(), Cursor::visible(3, 5));
1110    }
1111
1112    #[test]
1113    fn splice_self_cursor_wins() {
1114        let frame = Frame::new(vec![Line::new("a"), Line::new("b")]).with_cursor(Cursor::visible(0, 0));
1115        let other = Frame::new(vec![Line::new("X")]).with_cursor(Cursor::visible(0, 5));
1116        let frame = frame.splice(0, other);
1117        assert_eq!(frame.cursor(), Cursor::visible(0, 0));
1118    }
1119
1120    #[test]
1121    fn scroll_clips_to_viewport() {
1122        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c"), Line::new("d"), Line::new("e")]);
1123        let frame = frame.scroll(1, 3);
1124        assert_eq!(frame.lines().len(), 3);
1125        assert_eq!(frame.lines()[0].plain_text(), "b");
1126        assert_eq!(frame.lines()[1].plain_text(), "c");
1127        assert_eq!(frame.lines()[2].plain_text(), "d");
1128    }
1129
1130    #[test]
1131    fn scroll_adjusts_cursor() {
1132        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c"), Line::new("d"), Line::new("e")])
1133            .with_cursor(Cursor::visible(3, 2));
1134        let frame = frame.scroll(1, 4);
1135        assert_eq!(frame.cursor(), Cursor::visible(2, 2));
1136    }
1137
1138    #[test]
1139    fn scroll_hides_cursor_above_viewport() {
1140        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]).with_cursor(Cursor::visible(0, 0));
1141        let frame = frame.scroll(2, 1);
1142        assert!(!frame.cursor().is_visible);
1143    }
1144
1145    #[test]
1146    fn scroll_hides_cursor_below_viewport() {
1147        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c"), Line::new("d")])
1148            .with_cursor(Cursor::visible(3, 0));
1149        let frame = frame.scroll(0, 2);
1150        assert!(!frame.cursor().is_visible);
1151    }
1152
1153    #[test]
1154    fn scroll_zero_offset_is_truncate() {
1155        let frame = Frame::new(vec![Line::new("a"), Line::new("b"), Line::new("c")]);
1156        let frame = frame.scroll(0, 2);
1157        assert_eq!(frame.lines().len(), 2);
1158        assert_eq!(frame.lines()[0].plain_text(), "a");
1159        assert_eq!(frame.lines()[1].plain_text(), "b");
1160    }
1161}