Skip to main content

ftui_text/
view.rs

1#![forbid(unsafe_code)]
2
3//! Text view utilities for scrollable, wrapped display.
4//!
5//! The view precomputes "virtual lines" produced by wrapping so callers can
6//! perform deterministic viewport math (scroll by line/page, map source lines
7//! to wrapped lines, and compute visible ranges) without duplicating logic.
8
9use crate::rope::Rope;
10use crate::wrap::{WrapMode, WrapOptions, display_width, wrap_with_options};
11use std::ops::Range;
12
13/// Viewport size in terminal cells.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct Viewport {
16    /// Width in terminal columns.
17    pub width: usize,
18    /// Height in terminal rows.
19    pub height: usize,
20}
21
22impl Viewport {
23    /// Create a new viewport size.
24    #[must_use]
25    pub const fn new(width: usize, height: usize) -> Self {
26        Self { width, height }
27    }
28}
29
30/// A single wrapped (virtual) line in the view.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ViewLine {
33    /// The rendered text for this virtual line.
34    pub text: String,
35    /// The source (logical) line index from the original text.
36    pub source_line: usize,
37    /// True if this is a wrapped continuation of the source line.
38    pub is_wrap: bool,
39    /// Display width in terminal cells.
40    pub width: usize,
41}
42
43/// A scrollable, wrapped view over a text buffer.
44#[derive(Debug, Clone)]
45pub struct TextView {
46    text: Rope,
47    wrap: WrapMode,
48    width: usize,
49    lines: Vec<ViewLine>,
50    max_width: usize,
51    source_line_count: usize,
52}
53
54impl TextView {
55    /// Build a view from raw text, wrap mode, and viewport width.
56    #[must_use]
57    pub fn new(text: impl Into<Rope>, width: usize, wrap: WrapMode) -> Self {
58        let mut view = Self {
59            text: text.into(),
60            wrap,
61            width,
62            lines: Vec::new(),
63            max_width: 0,
64            source_line_count: 0,
65        };
66        view.rebuild();
67        view
68    }
69
70    /// Replace the text and recompute layout.
71    pub fn set_text(&mut self, text: impl Into<Rope>) {
72        self.text = text.into();
73        self.rebuild();
74    }
75
76    /// Update wrap mode and recompute layout.
77    pub fn set_wrap(&mut self, wrap: WrapMode) {
78        if self.wrap != wrap {
79            self.wrap = wrap;
80            self.rebuild();
81        }
82    }
83
84    /// Update viewport width and recompute layout.
85    pub fn set_width(&mut self, width: usize) {
86        if self.width != width {
87            self.width = width;
88            self.rebuild();
89        }
90    }
91
92    /// Current wrap mode.
93    #[must_use]
94    pub const fn wrap_mode(&self) -> WrapMode {
95        self.wrap
96    }
97
98    /// Current viewport width used for wrapping.
99    #[must_use]
100    pub const fn width(&self) -> usize {
101        self.width
102    }
103
104    /// Number of logical (source) lines in the text.
105    #[must_use]
106    pub const fn source_line_count(&self) -> usize {
107        self.source_line_count
108    }
109
110    /// Number of virtual (wrapped) lines.
111    #[must_use]
112    pub fn virtual_line_count(&self) -> usize {
113        self.lines.len()
114    }
115
116    /// Maximum display width across all virtual lines.
117    #[must_use]
118    pub const fn max_width(&self) -> usize {
119        self.max_width
120    }
121
122    /// Access all virtual lines.
123    #[must_use]
124    pub fn lines(&self) -> &[ViewLine] {
125        &self.lines
126    }
127
128    /// Map a source line index to its first virtual line index.
129    #[must_use]
130    pub fn source_to_virtual(&self, source_line: usize) -> Option<usize> {
131        self.lines
132            .iter()
133            .position(|line| line.source_line == source_line)
134    }
135
136    /// Map a virtual line index to its source line index.
137    #[must_use]
138    pub fn virtual_to_source(&self, virtual_line: usize) -> Option<usize> {
139        self.lines.get(virtual_line).map(|line| line.source_line)
140    }
141
142    /// Clamp scroll position to a valid range for the given viewport height.
143    #[must_use]
144    pub fn clamp_scroll(&self, scroll_y: usize, viewport_height: usize) -> usize {
145        let total = self.lines.len();
146        if total == 0 {
147            return 0;
148        }
149        if viewport_height == 0 {
150            return scroll_y.min(total);
151        }
152        let max_scroll = total.saturating_sub(viewport_height);
153        scroll_y.min(max_scroll)
154    }
155
156    /// Maximum scroll offset for the given viewport height.
157    #[must_use]
158    pub fn max_scroll(&self, viewport_height: usize) -> usize {
159        let total = self.lines.len();
160        if total == 0 {
161            return 0;
162        }
163        if viewport_height == 0 {
164            return total;
165        }
166        total.saturating_sub(viewport_height)
167    }
168
169    /// Compute the visible virtual line range for a scroll offset + viewport height.
170    #[must_use]
171    pub fn visible_range(&self, scroll_y: usize, viewport_height: usize) -> Range<usize> {
172        let total = self.lines.len();
173        if total == 0 || viewport_height == 0 {
174            return 0..0;
175        }
176        let scroll = self.clamp_scroll(scroll_y, viewport_height);
177        let end = (scroll + viewport_height).min(total);
178        scroll..end
179    }
180
181    /// Get the visible virtual lines for a scroll offset + viewport height.
182    #[must_use]
183    pub fn visible_lines(&self, scroll_y: usize, viewport_height: usize) -> &[ViewLine] {
184        let range = self.visible_range(scroll_y, viewport_height);
185        &self.lines[range]
186    }
187
188    /// Scroll so the given source line is at the top of the viewport.
189    /// Returns `None` if the source line doesn't exist.
190    #[must_use]
191    pub fn scroll_to_line(&self, source_line: usize, viewport_height: usize) -> Option<usize> {
192        let virtual_line = self.source_to_virtual(source_line)?;
193        Some(self.clamp_scroll(virtual_line, viewport_height))
194    }
195
196    /// Scroll to the top of the view.
197    #[must_use]
198    pub fn scroll_to_top(&self) -> usize {
199        0
200    }
201
202    /// Scroll to the bottom of the view.
203    #[must_use]
204    pub fn scroll_to_bottom(&self, viewport_height: usize) -> usize {
205        self.max_scroll(viewport_height)
206    }
207
208    /// Scroll by a line delta (positive or negative).
209    #[must_use]
210    pub fn scroll_by_lines(&self, scroll_y: usize, delta: isize, viewport_height: usize) -> usize {
211        let next = (scroll_y as i64) + (delta as i64);
212        let next = if next < 0 { 0 } else { next as usize };
213        self.clamp_scroll(next, viewport_height)
214    }
215
216    /// Scroll by a page delta (positive or negative).
217    #[must_use]
218    pub fn scroll_by_pages(&self, scroll_y: usize, pages: isize, viewport_height: usize) -> usize {
219        if viewport_height == 0 {
220            return self.clamp_scroll(scroll_y, viewport_height);
221        }
222        let delta = (viewport_height as i64) * (pages as i64);
223        let next = (scroll_y as i64) + delta;
224        let next = if next < 0 { 0 } else { next as usize };
225        self.clamp_scroll(next, viewport_height)
226    }
227
228    fn rebuild(&mut self) {
229        self.lines.clear();
230        self.max_width = 0;
231
232        let preserve_indent = self.wrap == WrapMode::Char;
233        let options = WrapOptions::new(self.width)
234            .mode(self.wrap)
235            .preserve_indent(preserve_indent);
236
237        let mut source_lines = 0;
238
239        for (source_line, line) in self.text.lines().enumerate() {
240            source_lines += 1;
241            let mut line_text = line.to_string();
242            if line_text.ends_with('\n') {
243                line_text.pop();
244            }
245
246            let wrapped = wrap_with_options(&line_text, &options);
247            if wrapped.is_empty() {
248                let width = 0;
249                self.lines.push(ViewLine {
250                    text: String::new(),
251                    source_line,
252                    is_wrap: false,
253                    width,
254                });
255                self.max_width = self.max_width.max(width);
256                continue;
257            }
258
259            for (idx, part) in wrapped.into_iter().enumerate() {
260                let width = display_width(&part);
261                self.max_width = self.max_width.max(width);
262                self.lines.push(ViewLine {
263                    text: part,
264                    source_line,
265                    is_wrap: idx > 0,
266                    width,
267                });
268            }
269        }
270
271        self.source_line_count = source_lines;
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::{TextView, Viewport};
278    use crate::wrap::WrapMode;
279
280    #[test]
281    fn view_basic_counts() {
282        let view = TextView::new("a\nbb", 10, WrapMode::None);
283        assert_eq!(view.source_line_count(), 2);
284        assert_eq!(view.virtual_line_count(), 2);
285        assert_eq!(view.max_width(), 2);
286    }
287
288    #[test]
289    fn view_wraps_word() {
290        let view = TextView::new("hello world", 5, WrapMode::Word);
291        let lines: Vec<&str> = view.lines().iter().map(|l| l.text.as_str()).collect();
292        assert_eq!(lines, vec!["hello", "world"]);
293    }
294
295    #[test]
296    fn view_wraps_cjk_by_cells() {
297        let view = TextView::new("你好世界", 4, WrapMode::Char);
298        let lines: Vec<&str> = view.lines().iter().map(|l| l.text.as_str()).collect();
299        assert_eq!(lines, vec!["你好", "世界"]);
300    }
301
302    #[test]
303    fn visible_range_clamps_scroll() {
304        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
305        let range = view.visible_range(5, 2);
306        assert_eq!(range, 1..3);
307    }
308
309    #[test]
310    fn scroll_to_line_clamps() {
311        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
312        let scroll = view.scroll_to_line(3, 2).expect("line 3 exists");
313        assert_eq!(scroll, 2);
314    }
315
316    #[test]
317    fn scroll_by_pages_moves_in_viewport_steps() {
318        let view = TextView::new("1\n2\n3\n4\n5", 10, WrapMode::None);
319        let scroll = view.scroll_by_pages(0, 1, 2);
320        assert_eq!(scroll, 2);
321        let back = view.scroll_by_pages(scroll, -1, 2);
322        assert_eq!(back, 0);
323    }
324
325    #[test]
326    fn scroll_to_bottom_respects_viewport() {
327        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
328        let bottom = view.scroll_to_bottom(2);
329        assert_eq!(bottom, 2);
330        let top = view.scroll_to_top();
331        assert_eq!(top, 0);
332    }
333
334    #[test]
335    fn visible_lines_returns_slice() {
336        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
337        let visible = view.visible_lines(1, 2);
338        let texts: Vec<&str> = visible.iter().map(|l| l.text.as_str()).collect();
339        assert_eq!(texts, vec!["b", "c"]);
340    }
341
342    #[test]
343    fn viewport_struct_is_copyable() {
344        let viewport = Viewport::new(80, 24);
345        let copy = viewport;
346        assert_eq!(copy.width, 80);
347        assert_eq!(copy.height, 24);
348    }
349
350    // ====== Empty text ======
351
352    #[test]
353    fn empty_text_view() {
354        let view = TextView::new("", 10, WrapMode::None);
355        assert_eq!(view.source_line_count(), 1); // empty text is still 1 source line
356        assert_eq!(view.virtual_line_count(), 1);
357        assert_eq!(view.max_width(), 0);
358    }
359
360    #[test]
361    fn empty_text_scroll() {
362        let view = TextView::new("", 10, WrapMode::None);
363        assert_eq!(view.max_scroll(5), 0);
364        assert_eq!(view.clamp_scroll(100, 5), 0);
365        assert_eq!(view.visible_range(0, 5), 0..1);
366    }
367
368    // ====== Source/virtual line mapping ======
369
370    #[test]
371    fn source_to_virtual_no_wrap() {
372        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
373        assert_eq!(view.source_to_virtual(0), Some(0));
374        assert_eq!(view.source_to_virtual(1), Some(1));
375        assert_eq!(view.source_to_virtual(2), Some(2));
376        assert_eq!(view.source_to_virtual(3), None);
377    }
378
379    #[test]
380    fn virtual_to_source_no_wrap() {
381        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
382        assert_eq!(view.virtual_to_source(0), Some(0));
383        assert_eq!(view.virtual_to_source(1), Some(1));
384        assert_eq!(view.virtual_to_source(2), Some(2));
385        assert_eq!(view.virtual_to_source(3), None);
386    }
387
388    #[test]
389    fn source_to_virtual_with_wrap() {
390        // "abcde" with width 3 wraps into 2 virtual lines
391        let view = TextView::new("abcde\nxy", 3, WrapMode::Char);
392        assert_eq!(view.source_to_virtual(0), Some(0)); // first virtual line of "abcde"
393        assert_eq!(view.source_to_virtual(1), Some(2)); // "xy" starts at virtual line 2
394    }
395
396    #[test]
397    fn virtual_to_source_with_wrap() {
398        let view = TextView::new("abcde\nxy", 3, WrapMode::Char);
399        assert_eq!(view.virtual_to_source(0), Some(0)); // first wrap of "abcde"
400        assert_eq!(view.virtual_to_source(1), Some(0)); // second wrap of "abcde"
401        assert_eq!(view.virtual_to_source(2), Some(1)); // "xy"
402    }
403
404    // ====== Wrap flag ======
405
406    #[test]
407    fn is_wrap_flag_set_correctly() {
408        let view = TextView::new("abcde", 3, WrapMode::Char);
409        let lines = view.lines();
410        assert!(!lines[0].is_wrap); // first segment is NOT a wrap
411        assert!(lines[1].is_wrap); // second segment IS a wrap continuation
412    }
413
414    // ====== set_text / set_wrap / set_width ======
415
416    #[test]
417    fn set_text_recomputes() {
418        let mut view = TextView::new("abc", 10, WrapMode::None);
419        assert_eq!(view.source_line_count(), 1);
420        view.set_text("a\nb\nc");
421        assert_eq!(view.source_line_count(), 3);
422        assert_eq!(view.virtual_line_count(), 3);
423    }
424
425    #[test]
426    fn set_wrap_recomputes() {
427        let mut view = TextView::new("hello world", 5, WrapMode::None);
428        let before = view.virtual_line_count();
429        view.set_wrap(WrapMode::Word);
430        let after = view.virtual_line_count();
431        // word wrap at width 5 should produce more lines than no wrap
432        assert!(after >= before);
433    }
434
435    #[test]
436    fn set_wrap_same_mode_is_noop() {
437        let mut view = TextView::new("hello", 10, WrapMode::None);
438        let count1 = view.virtual_line_count();
439        view.set_wrap(WrapMode::None); // same mode
440        assert_eq!(view.virtual_line_count(), count1);
441    }
442
443    #[test]
444    fn set_width_recomputes() {
445        let mut view = TextView::new("abcdef", 3, WrapMode::Char);
446        let count_narrow = view.virtual_line_count();
447        view.set_width(100);
448        let count_wide = view.virtual_line_count();
449        assert!(count_narrow > count_wide);
450    }
451
452    #[test]
453    fn set_width_same_is_noop() {
454        let mut view = TextView::new("abc", 10, WrapMode::None);
455        let count = view.virtual_line_count();
456        view.set_width(10);
457        assert_eq!(view.virtual_line_count(), count);
458    }
459
460    // ====== Accessors ======
461
462    #[test]
463    fn wrap_mode_accessor() {
464        let view = TextView::new("abc", 10, WrapMode::Word);
465        assert_eq!(view.wrap_mode(), WrapMode::Word);
466    }
467
468    #[test]
469    fn width_accessor() {
470        let view = TextView::new("abc", 42, WrapMode::None);
471        assert_eq!(view.width(), 42);
472    }
473
474    // ====== max_width ======
475
476    #[test]
477    fn max_width_across_lines() {
478        let view = TextView::new("ab\nabcde\nxy", 100, WrapMode::None);
479        assert_eq!(view.max_width(), 5); // "abcde" is the widest
480    }
481
482    #[test]
483    fn max_width_with_wide_chars() {
484        let view = TextView::new("\u{4E16}\u{754C}", 100, WrapMode::None); // "世界" = 4 cells
485        assert_eq!(view.max_width(), 4);
486    }
487
488    // ====== Scroll clamping ======
489
490    #[test]
491    fn clamp_scroll_within_bounds() {
492        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None); // 4 lines
493        assert_eq!(view.clamp_scroll(0, 2), 0);
494        assert_eq!(view.clamp_scroll(1, 2), 1);
495        assert_eq!(view.clamp_scroll(2, 2), 2); // max scroll for 4 lines, viewport 2
496        assert_eq!(view.clamp_scroll(3, 2), 2); // clamped
497        assert_eq!(view.clamp_scroll(100, 2), 2); // clamped
498    }
499
500    #[test]
501    fn clamp_scroll_viewport_larger_than_content() {
502        let view = TextView::new("a\nb", 10, WrapMode::None); // 2 lines
503        // viewport 10 > 2 lines, max_scroll = 0
504        assert_eq!(view.clamp_scroll(0, 10), 0);
505        assert_eq!(view.clamp_scroll(5, 10), 0);
506    }
507
508    #[test]
509    fn clamp_scroll_zero_viewport() {
510        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
511        // viewport 0: clamp to total (min of scroll_y and total)
512        let result = view.clamp_scroll(1, 0);
513        assert_eq!(result, 1);
514    }
515
516    // ====== max_scroll ======
517
518    #[test]
519    fn max_scroll_basic() {
520        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None); // 5 lines
521        assert_eq!(view.max_scroll(3), 2); // 5 - 3 = 2
522        assert_eq!(view.max_scroll(5), 0); // exact fit
523        assert_eq!(view.max_scroll(10), 0); // viewport bigger
524    }
525
526    #[test]
527    fn max_scroll_zero_viewport() {
528        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
529        assert_eq!(view.max_scroll(0), 3); // total lines
530    }
531
532    // ====== visible_range / visible_lines ======
533
534    #[test]
535    fn visible_range_basic() {
536        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
537        assert_eq!(view.visible_range(0, 3), 0..3);
538        assert_eq!(view.visible_range(1, 3), 1..4);
539        assert_eq!(view.visible_range(2, 3), 2..5);
540    }
541
542    #[test]
543    fn visible_range_zero_viewport() {
544        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
545        assert_eq!(view.visible_range(0, 0), 0..0);
546    }
547
548    #[test]
549    fn visible_lines_content() {
550        let view = TextView::new("alpha\nbeta\ngamma\ndelta", 10, WrapMode::None);
551        let visible = view.visible_lines(1, 2);
552        assert_eq!(visible.len(), 2);
553        assert_eq!(visible[0].text, "beta");
554        assert_eq!(visible[1].text, "gamma");
555    }
556
557    // ====== scroll_to_line ======
558
559    #[test]
560    fn scroll_to_line_basic() {
561        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
562        assert_eq!(view.scroll_to_line(0, 3), Some(0));
563        assert_eq!(view.scroll_to_line(2, 3), Some(2));
564        assert_eq!(view.scroll_to_line(4, 3), Some(2)); // clamped to max_scroll
565    }
566
567    #[test]
568    fn scroll_to_line_nonexistent() {
569        let view = TextView::new("a\nb", 10, WrapMode::None);
570        assert_eq!(view.scroll_to_line(5, 2), None);
571    }
572
573    // ====== scroll_by_lines ======
574
575    #[test]
576    fn scroll_by_lines_positive() {
577        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
578        assert_eq!(view.scroll_by_lines(0, 2, 3), 2);
579        assert_eq!(view.scroll_by_lines(0, 100, 3), 2); // clamped
580    }
581
582    #[test]
583    fn scroll_by_lines_negative() {
584        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
585        assert_eq!(view.scroll_by_lines(2, -1, 3), 1);
586        assert_eq!(view.scroll_by_lines(2, -100, 3), 0); // clamped to 0
587    }
588
589    // ====== scroll_by_pages ======
590
591    #[test]
592    fn scroll_by_pages_forward() {
593        // 10 lines, viewport 3: pages move by 3
594        let text = (0..10)
595            .map(|i| format!("line{i}"))
596            .collect::<Vec<_>>()
597            .join("\n");
598        let view = TextView::new(text.as_str(), 100, WrapMode::None);
599        assert_eq!(view.scroll_by_pages(0, 1, 3), 3);
600        assert_eq!(view.scroll_by_pages(0, 2, 3), 6);
601    }
602
603    #[test]
604    fn scroll_by_pages_backward() {
605        let text = (0..10)
606            .map(|i| format!("line{i}"))
607            .collect::<Vec<_>>()
608            .join("\n");
609        let view = TextView::new(text.as_str(), 100, WrapMode::None);
610        assert_eq!(view.scroll_by_pages(6, -1, 3), 3);
611        assert_eq!(view.scroll_by_pages(6, -3, 3), 0); // clamps to 0
612    }
613
614    #[test]
615    fn scroll_by_pages_zero_viewport() {
616        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
617        // zero viewport: should not panic, return clamped
618        let result = view.scroll_by_pages(0, 1, 0);
619        assert_eq!(result, 0);
620    }
621
622    // ====== scroll_to_top / scroll_to_bottom ======
623
624    #[test]
625    fn scroll_to_top_and_bottom() {
626        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
627        assert_eq!(view.scroll_to_top(), 0);
628        assert_eq!(view.scroll_to_bottom(3), 2);
629        assert_eq!(view.scroll_to_bottom(5), 0);
630        assert_eq!(view.scroll_to_bottom(1), 4);
631    }
632
633    // ====== Trailing newline ======
634
635    #[test]
636    fn trailing_newline_text() {
637        let view = TextView::new("a\nb\n", 10, WrapMode::None);
638        assert_eq!(view.source_line_count(), 3);
639        // Last source line is empty
640        let lines = view.lines();
641        assert_eq!(lines.last().unwrap().text, "");
642    }
643
644    // ====== Only newlines ======
645
646    #[test]
647    fn only_newlines() {
648        let view = TextView::new("\n\n\n", 10, WrapMode::None);
649        assert_eq!(view.source_line_count(), 4); // 3 newlines = 4 lines
650        assert_eq!(view.virtual_line_count(), 4);
651        for line in view.lines() {
652            assert_eq!(line.text, "");
653        }
654    }
655
656    // ====== ViewLine fields ======
657
658    #[test]
659    fn view_line_source_line_tracking() {
660        let view = TextView::new("ab\ncd\nef", 10, WrapMode::None);
661        for (i, line) in view.lines().iter().enumerate() {
662            assert_eq!(line.source_line, i);
663            assert!(!line.is_wrap);
664        }
665    }
666
667    #[test]
668    fn view_line_width_tracking() {
669        let view = TextView::new("ab\nabcde\n\u{4E16}", 10, WrapMode::None);
670        assert_eq!(view.lines()[0].width, 2);
671        assert_eq!(view.lines()[1].width, 5);
672        assert_eq!(view.lines()[2].width, 2); // CJK = 2 cells
673    }
674
675    // ====== Viewport struct ======
676
677    #[test]
678    fn viewport_default() {
679        let v = Viewport::default();
680        assert_eq!(v.width, 0);
681        assert_eq!(v.height, 0);
682    }
683
684    #[test]
685    fn viewport_equality() {
686        assert_eq!(Viewport::new(80, 24), Viewport::new(80, 24));
687        assert_ne!(Viewport::new(80, 24), Viewport::new(120, 24));
688    }
689}