Skip to main content

bubbles/
viewport.rs

1//! Scrollable viewport component.
2//!
3//! This module provides a viewport for rendering scrollable content in TUI
4//! applications.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::viewport::Viewport;
10//!
11//! let mut viewport = Viewport::new(80, 24);
12//! viewport.set_content("Line 1\nLine 2\nLine 3");
13//!
14//! // Scroll down
15//! viewport.scroll_down(1);
16//! ```
17
18use crate::key::{Binding, matches};
19use bubbletea::{Cmd, KeyMsg, Message, Model, MouseMsg};
20use lipgloss::Style;
21use unicode_width::UnicodeWidthChar;
22
23/// Key bindings for viewport navigation.
24#[derive(Debug, Clone)]
25pub struct KeyMap {
26    /// Page down binding.
27    pub page_down: Binding,
28    /// Page up binding.
29    pub page_up: Binding,
30    /// Half page up binding.
31    pub half_page_up: Binding,
32    /// Half page down binding.
33    pub half_page_down: Binding,
34    /// Down one line binding.
35    pub down: Binding,
36    /// Up one line binding.
37    pub up: Binding,
38    /// Scroll left binding.
39    pub left: Binding,
40    /// Scroll right binding.
41    pub right: Binding,
42}
43
44impl Default for KeyMap {
45    fn default() -> Self {
46        Self {
47            page_down: Binding::new()
48                .keys(&["pgdown", " ", "f"])
49                .help("f/pgdn", "page down"),
50            page_up: Binding::new()
51                .keys(&["pgup", "b"])
52                .help("b/pgup", "page up"),
53            half_page_up: Binding::new().keys(&["u", "ctrl+u"]).help("u", "½ page up"),
54            half_page_down: Binding::new()
55                .keys(&["d", "ctrl+d"])
56                .help("d", "½ page down"),
57            up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
58            down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
59            left: Binding::new().keys(&["left", "h"]).help("←/h", "move left"),
60            right: Binding::new()
61                .keys(&["right", "l"])
62                .help("→/l", "move right"),
63        }
64    }
65}
66
67/// Viewport model for scrollable content.
68#[derive(Debug, Clone)]
69pub struct Viewport {
70    /// Width of the viewport.
71    pub width: usize,
72    /// Height of the viewport.
73    pub height: usize,
74    /// Key bindings for navigation.
75    pub key_map: KeyMap,
76    /// Whether mouse wheel scrolling is enabled.
77    pub mouse_wheel_enabled: bool,
78    /// Number of lines to scroll per mouse wheel tick.
79    pub mouse_wheel_delta: usize,
80    /// Vertical scroll offset.
81    y_offset: usize,
82    /// Horizontal scroll offset.
83    x_offset: usize,
84    /// Horizontal scroll step size.
85    horizontal_step: usize,
86    /// Style for rendering the viewport.
87    pub style: Style,
88    /// Content lines.
89    lines: Vec<String>,
90    /// Width of the longest line.
91    longest_line_width: usize,
92}
93
94impl Viewport {
95    /// Creates a new viewport with the given dimensions.
96    #[must_use]
97    pub fn new(width: usize, height: usize) -> Self {
98        Self {
99            width,
100            height,
101            key_map: KeyMap::default(),
102            mouse_wheel_enabled: true,
103            mouse_wheel_delta: 3,
104            y_offset: 0,
105            x_offset: 0,
106            horizontal_step: 0,
107            style: Style::new(),
108            lines: Vec::new(),
109            longest_line_width: 0,
110        }
111    }
112
113    /// Sets the content of the viewport.
114    pub fn set_content(&mut self, content: &str) {
115        let normalized = content.replace("\r\n", "\n");
116        self.lines = normalized.split('\n').map(String::from).collect();
117        self.longest_line_width = self
118            .lines
119            .iter()
120            .map(|l| visible_width(l))
121            .max()
122            .unwrap_or(0);
123
124        if self.y_offset > self.lines.len().saturating_sub(1) {
125            self.goto_bottom();
126        }
127    }
128
129    /// Returns the vertical scroll offset.
130    #[must_use]
131    pub fn y_offset(&self) -> usize {
132        self.y_offset
133    }
134
135    /// Sets the vertical scroll offset.
136    pub fn set_y_offset(&mut self, n: usize) {
137        self.y_offset = n.min(self.max_y_offset());
138    }
139
140    /// Returns the horizontal scroll offset.
141    #[must_use]
142    pub fn x_offset(&self) -> usize {
143        self.x_offset
144    }
145
146    /// Sets the horizontal scroll offset.
147    pub fn set_x_offset(&mut self, n: usize) {
148        self.x_offset = n.min(self.longest_line_width.saturating_sub(self.width));
149    }
150
151    /// Sets the horizontal scroll step size.
152    pub fn set_horizontal_step(&mut self, n: usize) {
153        self.horizontal_step = n;
154    }
155
156    /// Returns whether the viewport is at the top.
157    #[must_use]
158    pub fn at_top(&self) -> bool {
159        self.y_offset == 0
160    }
161
162    /// Returns whether the viewport is at the bottom.
163    #[must_use]
164    pub fn at_bottom(&self) -> bool {
165        self.y_offset >= self.max_y_offset()
166    }
167
168    /// Returns whether the viewport is past the bottom.
169    #[must_use]
170    pub fn past_bottom(&self) -> bool {
171        self.y_offset > self.max_y_offset()
172    }
173
174    /// Returns the scroll percentage (0.0 to 1.0).
175    #[must_use]
176    pub fn scroll_percent(&self) -> f64 {
177        if self.height >= self.lines.len() {
178            return 1.0;
179        }
180        let y = self.y_offset as f64;
181        let h = self.height as f64;
182        let t = self.lines.len() as f64;
183        let v = y / (t - h);
184        v.clamp(0.0, 1.0)
185    }
186
187    /// Returns the horizontal scroll percentage (0.0 to 1.0).
188    #[must_use]
189    pub fn horizontal_scroll_percent(&self) -> f64 {
190        if self.longest_line_width <= self.width {
191            return 1.0;
192        }
193        let x = self.x_offset as f64;
194        let scrollable = (self.longest_line_width - self.width) as f64;
195        let v = x / scrollable;
196        v.clamp(0.0, 1.0)
197    }
198
199    /// Returns the total number of lines.
200    #[must_use]
201    pub fn total_line_count(&self) -> usize {
202        self.lines.len()
203    }
204
205    /// Returns the number of visible lines.
206    #[must_use]
207    pub fn visible_line_count(&self) -> usize {
208        self.visible_lines().len()
209    }
210
211    /// Returns the maximum Y offset.
212    fn max_y_offset(&self) -> usize {
213        self.lines.len().saturating_sub(self.content_height())
214    }
215
216    /// Returns the currently visible lines.
217    fn visible_lines(&self) -> Vec<String> {
218        if self.lines.is_empty() {
219            return Vec::new();
220        }
221
222        let content_height = self.content_height();
223        if content_height == 0 {
224            return Vec::new();
225        }
226
227        let top = self.y_offset.min(self.lines.len());
228        let bottom = top.saturating_add(content_height).min(self.lines.len());
229
230        let visible = &self.lines[top..bottom];
231        let content_width = self.content_width();
232        if (self.x_offset == 0 && self.longest_line_width <= content_width) || content_width == 0 {
233            return visible.to_vec();
234        }
235
236        visible
237            .iter()
238            .map(|line| cut_line(line, self.x_offset, content_width))
239            .collect()
240    }
241
242    /// Scrolls down by the given number of lines.
243    pub fn scroll_down(&mut self, n: usize) {
244        if self.at_bottom() || n == 0 || self.lines.is_empty() {
245            return;
246        }
247        self.set_y_offset(self.y_offset + n);
248    }
249
250    /// Scrolls up by the given number of lines.
251    pub fn scroll_up(&mut self, n: usize) {
252        if self.at_top() || n == 0 || self.lines.is_empty() {
253            return;
254        }
255        self.set_y_offset(self.y_offset.saturating_sub(n));
256    }
257
258    /// Scrolls left by the given number of columns.
259    pub fn scroll_left(&mut self, n: usize) {
260        self.set_x_offset(self.x_offset.saturating_sub(n));
261    }
262
263    /// Scrolls right by the given number of columns.
264    pub fn scroll_right(&mut self, n: usize) {
265        self.set_x_offset(self.x_offset + n);
266    }
267
268    /// Moves down one page.
269    pub fn page_down(&mut self) {
270        if !self.at_bottom() {
271            self.scroll_down(self.height);
272        }
273    }
274
275    /// Moves up one page.
276    pub fn page_up(&mut self) {
277        if !self.at_top() {
278            self.scroll_up(self.height);
279        }
280    }
281
282    /// Moves down half a page.
283    pub fn half_page_down(&mut self) {
284        if !self.at_bottom() {
285            self.scroll_down(self.height / 2);
286        }
287    }
288
289    /// Moves up half a page.
290    pub fn half_page_up(&mut self) {
291        if !self.at_top() {
292            self.scroll_up(self.height / 2);
293        }
294    }
295
296    /// Goes to the top.
297    pub fn goto_top(&mut self) {
298        self.set_y_offset(0);
299    }
300
301    /// Goes to the bottom.
302    pub fn goto_bottom(&mut self) {
303        self.set_y_offset(self.max_y_offset());
304    }
305
306    /// Updates the viewport based on key/mouse input.
307    pub fn update(&mut self, msg: &Message) {
308        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
309            let key_str = key.to_string();
310
311            if matches(&key_str, &[&self.key_map.page_down]) {
312                self.page_down();
313            } else if matches(&key_str, &[&self.key_map.page_up]) {
314                self.page_up();
315            } else if matches(&key_str, &[&self.key_map.half_page_down]) {
316                self.half_page_down();
317            } else if matches(&key_str, &[&self.key_map.half_page_up]) {
318                self.half_page_up();
319            } else if matches(&key_str, &[&self.key_map.down]) {
320                self.scroll_down(1);
321            } else if matches(&key_str, &[&self.key_map.up]) {
322                self.scroll_up(1);
323            } else if matches(&key_str, &[&self.key_map.left]) {
324                self.scroll_left(self.horizontal_step);
325            } else if matches(&key_str, &[&self.key_map.right]) {
326                self.scroll_right(self.horizontal_step);
327            }
328            return;
329        }
330
331        if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
332            if !self.mouse_wheel_enabled || mouse.action != bubbletea::MouseAction::Press {
333                return;
334            }
335            match mouse.button {
336                bubbletea::MouseButton::WheelUp => {
337                    if mouse.shift {
338                        self.scroll_left(self.horizontal_step);
339                    } else {
340                        self.scroll_up(self.mouse_wheel_delta);
341                    }
342                }
343                bubbletea::MouseButton::WheelDown => {
344                    if mouse.shift {
345                        self.scroll_right(self.horizontal_step);
346                    } else {
347                        self.scroll_down(self.mouse_wheel_delta);
348                    }
349                }
350                bubbletea::MouseButton::WheelLeft => self.scroll_left(self.horizontal_step),
351                bubbletea::MouseButton::WheelRight => self.scroll_right(self.horizontal_step),
352                _ => {}
353            }
354        }
355    }
356
357    /// Renders the viewport content.
358    #[must_use]
359    pub fn view(&self) -> String {
360        let mut width = self.width;
361        if let Some(style_width) = self.style.get_width()
362            && style_width > 0
363        {
364            width = width.min(style_width as usize);
365        }
366
367        let mut height = self.height;
368        if let Some(style_height) = self.style.get_height()
369            && style_height > 0
370        {
371            height = height.min(style_height as usize);
372        }
373
374        let frame_width = self.style.get_horizontal_frame_size();
375        let frame_height = self.style.get_vertical_frame_size();
376        let content_width = width.saturating_sub(frame_width);
377        let content_height = height.saturating_sub(frame_height);
378        let lines = self.visible_lines();
379        let contents = if content_width == 0 || content_height == 0 {
380            String::new()
381        } else {
382            let content_style = Style::new()
383                .width(as_u16(content_width))
384                .height(as_u16(content_height))
385                .max_width(as_u16(content_width))
386                .max_height(as_u16(content_height));
387            content_style.render(&lines.join("\n"))
388        };
389
390        self.style.render(&contents)
391    }
392
393    fn content_width(&self) -> usize {
394        self.width
395            .saturating_sub(self.style.get_horizontal_frame_size())
396    }
397
398    fn content_height(&self) -> usize {
399        self.height
400            .saturating_sub(self.style.get_vertical_frame_size())
401    }
402}
403
404/// Implement the Model trait for standalone bubbletea usage.
405impl Model for Viewport {
406    fn init(&self) -> Option<Cmd> {
407        // Viewport doesn't need initialization
408        None
409    }
410
411    fn update(&mut self, msg: Message) -> Option<Cmd> {
412        // Call the existing update method
413        Viewport::update(self, &msg);
414        None
415    }
416
417    fn view(&self) -> String {
418        Viewport::view(self)
419    }
420}
421
422fn as_u16(value: usize) -> u16 {
423    value.min(u16::MAX as usize) as u16
424}
425
426fn visible_width(s: &str) -> usize {
427    let mut width = 0;
428    let mut in_escape = false;
429    let mut in_csi = false;
430
431    for c in s.chars() {
432        if c == '\x1b' {
433            in_escape = true;
434            continue;
435        }
436        if in_escape {
437            in_escape = false;
438            if c == '[' {
439                // CSI sequence - wait for final byte
440                in_csi = true;
441            }
442            // Simple escape (e.g., \x1b7) - single char after ESC is consumed
443            continue;
444        }
445        if in_csi {
446            // CSI sequences end with a final byte in 0x40-0x7E (@ through ~)
447            if ('@'..='~').contains(&c) {
448                in_csi = false;
449            }
450            continue;
451        }
452        width += UnicodeWidthChar::width(c).unwrap_or(0);
453    }
454
455    width
456}
457
458fn cut_line(line: &str, start: usize, width: usize) -> String {
459    if width == 0 {
460        return String::new();
461    }
462
463    let end = start.saturating_add(width);
464    let mut result = String::new();
465    let mut in_escape = false;
466    let mut in_csi = false;
467    let mut visible = 0;
468
469    for c in line.chars() {
470        if c == '\x1b' {
471            in_escape = true;
472            result.push(c);
473            continue;
474        }
475        if in_escape {
476            in_escape = false;
477            result.push(c);
478            if c == '[' {
479                // CSI sequence - wait for final byte
480                in_csi = true;
481            }
482            // Simple escape (e.g., \x1b7) - single char after ESC is consumed
483            continue;
484        }
485        if in_csi {
486            result.push(c);
487            // CSI sequences end with a final byte in 0x40-0x7E (@ through ~)
488            if ('@'..='~').contains(&c) {
489                in_csi = false;
490            }
491            continue;
492        }
493
494        let cw = UnicodeWidthChar::width(c).unwrap_or(0);
495        if visible + cw <= start {
496            visible += cw;
497            continue;
498        }
499        if visible >= end {
500            break;
501        }
502        result.push(c);
503        visible += cw;
504    }
505
506    result
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_viewport_new() {
515        let v = Viewport::new(80, 24);
516        assert_eq!(v.width, 80);
517        assert_eq!(v.height, 24);
518        assert!(v.mouse_wheel_enabled);
519    }
520
521    #[test]
522    fn test_viewport_set_content() {
523        let mut v = Viewport::new(80, 5);
524        v.set_content("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7");
525        assert_eq!(v.total_line_count(), 7);
526    }
527
528    #[test]
529    fn test_viewport_at_top_bottom() {
530        let mut v = Viewport::new(80, 3);
531        v.set_content("1\n2\n3\n4\n5");
532
533        assert!(v.at_top());
534        assert!(!v.at_bottom());
535
536        v.goto_bottom();
537        assert!(!v.at_top());
538        assert!(v.at_bottom());
539    }
540
541    #[test]
542    fn test_viewport_scroll() {
543        let mut v = Viewport::new(80, 3);
544        v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
545
546        assert_eq!(v.y_offset(), 0);
547
548        v.scroll_down(2);
549        assert_eq!(v.y_offset(), 2);
550
551        v.scroll_up(1);
552        assert_eq!(v.y_offset(), 1);
553    }
554
555    #[test]
556    fn test_viewport_page_navigation() {
557        let mut v = Viewport::new(80, 3);
558        v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
559
560        v.page_down();
561        assert_eq!(v.y_offset(), 3);
562
563        v.page_up();
564        assert_eq!(v.y_offset(), 0);
565    }
566
567    #[test]
568    fn test_viewport_scroll_percent() {
569        let mut v = Viewport::new(80, 5);
570        v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
571
572        assert!((v.scroll_percent() - 0.0).abs() < 0.01);
573
574        v.goto_bottom();
575        assert!((v.scroll_percent() - 1.0).abs() < 0.01);
576    }
577
578    #[test]
579    fn test_viewport_view() {
580        let mut v = Viewport::new(80, 3);
581        v.set_content("Line 1\nLine 2\nLine 3\nLine 4");
582
583        let view = v.view();
584        assert!(view.contains("Line 1"));
585        assert!(view.contains("Line 2"));
586        assert!(view.contains("Line 3"));
587        assert!(!view.contains("Line 4"));
588    }
589
590    #[test]
591    fn test_viewport_view_pads_to_dimensions() {
592        let mut v = Viewport::new(4, 2);
593        v.set_content("a");
594        assert_eq!(v.view(), "a   \n    ");
595    }
596
597    #[test]
598    fn test_viewport_frame_affects_visible_height() {
599        let mut v = Viewport::new(10, 5);
600        v.style = Style::new().padding(1);
601        v.set_content("1\n2\n3\n4\n5\n6");
602        assert_eq!(v.visible_line_count(), 3);
603
604        v.goto_bottom();
605        assert_eq!(v.y_offset(), 3);
606    }
607
608    #[test]
609    fn test_viewport_horizontal_scroll() {
610        let mut v = Viewport::new(10, 5);
611        v.set_horizontal_step(5);
612        v.set_content("This is a very long line that exceeds the width");
613
614        assert_eq!(v.x_offset(), 0);
615
616        v.scroll_right(5);
617        assert_eq!(v.x_offset(), 5);
618
619        v.scroll_left(3);
620        assert_eq!(v.x_offset(), 2);
621    }
622
623    #[test]
624    fn test_viewport_horizontal_scroll_uses_display_width() {
625        let mut v = Viewport::new(4, 1);
626        v.set_content("日本語abc");
627        v.set_x_offset(2);
628        assert_eq!(v.view(), "本語");
629    }
630
631    #[test]
632    fn test_viewport_mouse_wheel_shift_scrolls_horizontal() {
633        let mut v = Viewport::new(10, 2);
634        v.set_content("This is a very long line that exceeds the width");
635        v.set_horizontal_step(2);
636
637        let down_shift = MouseMsg {
638            button: bubbletea::MouseButton::WheelDown,
639            shift: true,
640            ..MouseMsg::default()
641        };
642        v.update(&Message::new(down_shift));
643        assert_eq!(v.x_offset(), 2);
644
645        let up_shift = MouseMsg {
646            button: bubbletea::MouseButton::WheelUp,
647            shift: true,
648            ..MouseMsg::default()
649        };
650        v.update(&Message::new(up_shift));
651        assert_eq!(v.x_offset(), 0);
652    }
653
654    #[test]
655    fn test_viewport_mouse_wheel_ignores_release() {
656        let mut v = Viewport::new(10, 2);
657        v.set_content("1\n2\n3\n4");
658
659        let release = MouseMsg {
660            button: bubbletea::MouseButton::WheelDown,
661            action: bubbletea::MouseAction::Release,
662            ..MouseMsg::default()
663        };
664        v.update(&Message::new(release));
665        assert_eq!(v.y_offset(), 0);
666    }
667
668    #[test]
669    fn test_viewport_empty_content() {
670        let v = Viewport::new(80, 24);
671        assert_eq!(v.total_line_count(), 0);
672        assert!(v.at_top());
673        assert!(v.at_bottom());
674    }
675
676    #[test]
677    fn test_viewport_model_init_returns_none() {
678        let v = Viewport::new(80, 24);
679        assert!(Model::init(&v).is_none());
680    }
681
682    #[test]
683    fn test_viewport_model_update_scrolls() {
684        let mut v = Viewport::new(10, 2);
685        v.set_content("1\n2\n3\n4");
686        assert_eq!(v.y_offset(), 0);
687
688        let down_msg = Message::new(KeyMsg::from_char('j'));
689        let result = Model::update(&mut v, down_msg);
690        assert!(result.is_none());
691        assert_eq!(v.y_offset(), 1);
692    }
693
694    #[test]
695    fn test_viewport_model_view_matches_view() {
696        let mut v = Viewport::new(10, 2);
697        v.set_content("Line 1\nLine 2\nLine 3");
698        assert_eq!(Model::view(&v), v.view());
699    }
700
701    #[test]
702    fn test_visible_width_with_non_sgr_csi_sequences() {
703        // CSI sequences ending with characters other than 'm' should be handled
704        // Test: clear screen \x1b[2J followed by "Hello" should have width 5
705        assert_eq!(visible_width("\x1b[2JHello"), 5);
706        // Test: cursor position \x1b[H followed by "World" should have width 5
707        assert_eq!(visible_width("\x1b[HWorld"), 5);
708        // Test: mixed - SGR sequence followed by non-SGR CSI followed by text
709        assert_eq!(visible_width("\x1b[31m\x1b[2KRed"), 3);
710        // Test: text followed by CSI sequence (erase to end of line)
711        assert_eq!(visible_width("Start\x1b[K"), 5);
712    }
713
714    #[test]
715    fn test_visible_width_with_simple_escapes() {
716        // Simple escapes like save/restore cursor should be handled
717        // \x1b7 = save cursor, \x1b8 = restore cursor
718        assert_eq!(visible_width("\x1b7Text\x1b8"), 4);
719    }
720
721    #[test]
722    fn test_cut_line_with_non_sgr_csi_sequences() {
723        // cut_line should properly handle non-SGR CSI sequences
724        let line = "\x1b[2JHello World";
725        // Start at 0, width 5 should give escape sequence + "Hello"
726        assert_eq!(cut_line(line, 0, 5), "\x1b[2JHello");
727        // Start at 6, width 5 - escape sequences at beginning are preserved
728        // (important for color codes, harmless for other escape types)
729        assert_eq!(cut_line(line, 6, 5), "\x1b[2JWorld");
730    }
731}