Skip to main content

rpi_tui/
scroll_view.rs

1//! ScrollView - Scrollable container component.
2//!
3//! Implements scrolling with follow-end behavior for transcript display.
4//! Follows the design from `tui-plan.md`.
5
6use std::any::Any;
7use std::sync::{Arc, Mutex};
8
9use super::component::Component;
10use super::layout_node::{LayoutNode, LayoutNodeProvider, ScrollNode};
11
12/// Follow mode for ScrollView.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum FollowMode {
15    /// No automatic follow behavior.
16    None,
17    /// Automatically scroll to end when new content is added while at end.
18    #[default]
19    End,
20}
21
22/// Overscroll behavior.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum OverscrollMode {
25    /// No overscroll.
26    #[default]
27    None,
28    /// Chain unused scroll delta to parent scroll view.
29    Chain,
30    /// Contain scroll within this view.
31    Contain,
32}
33
34/// Scrollbar visibility options.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ScrollbarMode {
37    /// Never show scrollbar.
38    Hidden,
39    /// Show scrollbar automatically when content exceeds viewport.
40    #[default]
41    Auto,
42    /// Always show scrollbar.
43    Always,
44}
45
46/// Options for ScrollView.
47#[derive(Debug, Clone, Default)]
48pub struct ScrollViewOptions {
49    /// Axis for scrolling (only vertical is currently supported).
50    pub axis: Option<ScrollAxis>,
51    /// Follow mode for automatic scrolling.
52    pub follow: FollowMode,
53    /// Designate this view as the fallback target for global scroll actions.
54    pub primary: bool,
55    /// Overscroll behavior.
56    pub overscroll: OverscrollMode,
57    /// Scrollbar visibility.
58    pub scrollbar: ScrollbarMode,
59    /// Delay in milliseconds before hiding auto scrollbar.
60    pub scrollbar_hide_delay_ms: Option<u64>,
61}
62
63/// Scroll axis (only vertical is supported currently).
64#[derive(Debug, Clone, Copy, Default)]
65pub enum ScrollAxis {
66    #[default]
67    Vertical,
68}
69
70/// Internal state for ScrollView.
71#[derive(Debug, Clone)]
72struct ScrollViewState {
73    /// Current scroll position (top row of visible area).
74    scroll_top: usize,
75    /// Whether we're following the end.
76    is_following_end: bool,
77    /// Viewport height (set during render).
78    viewport_height: usize,
79    /// Content height (set during render).
80    content_height: usize,
81    /// Whether follow was suppressed at the end.
82    follow_suppressed_at_end: bool,
83    /// Whether the scrollbar is temporarily visible (for auto mode).
84    transient_scrollbar_visible: bool,
85}
86
87impl ScrollViewState {
88    fn new(follow: FollowMode) -> Self {
89        Self {
90            scroll_top: 0,
91            is_following_end: follow == FollowMode::End,
92            viewport_height: 0,
93            content_height: 0,
94            follow_suppressed_at_end: false,
95            transient_scrollbar_visible: false,
96        }
97    }
98}
99
100#[derive(Clone)]
101struct ScrollRenderCache {
102    content_width: usize,
103    lines: Arc<Vec<String>>,
104}
105
106/// ScrollView - A scrollable container.
107///
108/// Provides vertical scrolling with optional follow-end behavior.
109/// In constrained layout, the child is measured at unbounded height
110/// and clipped to the allocated viewport.
111#[derive(Clone)]
112pub struct ScrollView {
113    child: Arc<dyn Component>,
114    options: ScrollViewOptions,
115    state: Arc<Mutex<ScrollViewState>>,
116    /// Last fully rendered child. Pure scroll frames only change which rows
117    /// are visible, so rebuilding the entire transcript would be wasted work.
118    render_cache: Arc<Mutex<Option<ScrollRenderCache>>>,
119    /// Callback to request a render.
120    request_render_callback: Arc<Mutex<Option<Arc<dyn Fn() + Send + Sync>>>>,
121}
122
123impl ScrollView {
124    /// Create a new ScrollView with the given child.
125    pub fn new(child: Arc<dyn Component>, options: ScrollViewOptions) -> Self {
126        let follow = options.follow;
127        Self {
128            child,
129            options,
130            state: Arc::new(Mutex::new(ScrollViewState::new(follow))),
131            render_cache: Arc::new(Mutex::new(None)),
132            request_render_callback: Arc::new(Mutex::new(None)),
133        }
134    }
135
136    /// Create a simple ScrollView with default options.
137    pub fn simple(child: Arc<dyn Component>) -> Self {
138        Self::new(child, ScrollViewOptions::default())
139    }
140
141    /// Create a primary ScrollView with follow-end behavior.
142    pub fn primary(child: Arc<dyn Component>) -> Self {
143        Self::new(
144            child,
145            ScrollViewOptions {
146                follow: FollowMode::End,
147                primary: true,
148                ..Default::default()
149            },
150        )
151    }
152
153    /// Get the child component.
154    pub fn child(&self) -> &Arc<dyn Component> {
155        &self.child
156    }
157
158    /// Get the current scroll position.
159    pub fn scroll_top(&self) -> usize {
160        self.state.lock().map(|s| s.scroll_top).unwrap_or(0)
161    }
162
163    /// Check if following the end.
164    pub fn is_following_end(&self) -> bool {
165        self.state
166            .lock()
167            .map(|s| s.is_following_end)
168            .unwrap_or(false)
169    }
170
171    /// Whether this view can automatically follow newly appended content.
172    pub fn follows_end(&self) -> bool {
173        self.options.follow == FollowMode::End
174    }
175
176    /// Get the viewport height.
177    pub fn viewport_height(&self) -> usize {
178        self.state.lock().map(|s| s.viewport_height).unwrap_or(0)
179    }
180
181    /// Get the content height.
182    pub fn content_height(&self) -> usize {
183        self.state.lock().map(|s| s.content_height).unwrap_or(0)
184    }
185
186    /// Check if this is the primary scroll view.
187    pub fn is_primary(&self) -> bool {
188        self.options.primary
189    }
190
191    /// Check if scrollbar is visible.
192    pub fn is_scrollbar_visible(&self) -> bool {
193        match self.options.scrollbar {
194            ScrollbarMode::Always => self.viewport_height() > 0,
195            ScrollbarMode::Auto => {
196                self.content_height() > self.viewport_height()
197                    && self
198                        .state
199                        .lock()
200                        .map(|s| s.transient_scrollbar_visible)
201                        .unwrap_or(false)
202            }
203            ScrollbarMode::Hidden => false,
204        }
205    }
206
207    /// Get the content width (accounting for scrollbar).
208    pub fn get_content_width(&self, width: usize) -> usize {
209        if self.options.scrollbar == ScrollbarMode::Always && width > 1 {
210            width - 1
211        } else {
212            width
213        }
214    }
215
216    /// Scroll by the given number of lines.
217    ///
218    /// Returns the unused delta if scrolling hit the boundary.
219    /// This enables scroll chaining for nested scroll views.
220    pub fn scroll_by(&self, delta: i32) -> i32 {
221        if delta == 0 {
222            return 0;
223        }
224
225        if let Ok(mut state) = self.state.lock() {
226            let max_scroll = state.content_height.saturating_sub(state.viewport_height);
227
228            // If following end, start from the end position
229            let start = if state.is_following_end {
230                max_scroll
231            } else {
232                state.scroll_top
233            };
234
235            let new_top = if delta > 0 {
236                start.saturating_add(delta as usize)
237            } else {
238                start.saturating_sub((-delta) as usize)
239            };
240
241            let clamped = new_top.min(max_scroll);
242            let moved = (clamped as i32) - (start as i32);
243
244            state.scroll_top = clamped;
245            state.is_following_end =
246                self.options.follow == FollowMode::End && clamped >= max_scroll;
247            state.follow_suppressed_at_end = false;
248
249            // Mark scrollbar activity
250            if moved != 0 {
251                state.transient_scrollbar_visible = true;
252            }
253
254            delta - moved
255        } else {
256            delta
257        }
258    }
259
260    /// Scroll to a specific position.
261    pub fn scroll_to(&self, position: usize) {
262        if let Ok(mut state) = self.state.lock() {
263            let max_scroll = state.content_height.saturating_sub(state.viewport_height);
264            state.scroll_top = position.min(max_scroll);
265
266            if self.options.follow == FollowMode::End {
267                state.is_following_end = state.scroll_top >= max_scroll;
268            }
269            state.follow_suppressed_at_end = false;
270            state.transient_scrollbar_visible = true;
271        }
272        self.request_render();
273    }
274
275    /// Scroll to the start.
276    pub fn scroll_to_start(&self) {
277        if let Ok(mut state) = self.state.lock() {
278            let content_fits = state.content_height <= state.viewport_height;
279            let following_end = self.options.follow == FollowMode::End && content_fits;
280            let changed = state.scroll_top != 0 || state.is_following_end != following_end;
281            state.scroll_top = 0;
282            state.is_following_end = following_end;
283            state.follow_suppressed_at_end = false;
284
285            if changed {
286                state.transient_scrollbar_visible = true;
287            }
288        }
289        self.request_render();
290    }
291
292    /// Scroll to the end.
293    pub fn scroll_to_end(&self) {
294        if let Ok(mut state) = self.state.lock() {
295            let max_scroll = state.content_height.saturating_sub(state.viewport_height);
296            let changed = state.scroll_top != max_scroll || !state.is_following_end;
297            state.scroll_top = max_scroll;
298            state.is_following_end = self.options.follow == FollowMode::End;
299            state.follow_suppressed_at_end = false;
300
301            if changed {
302                state.transient_scrollbar_visible = true;
303            }
304        }
305        self.request_render();
306    }
307
308    /// Update layout information (called by the layout system).
309    pub fn update_layout(
310        &self,
311        content_height: usize,
312        viewport_height: usize,
313        request_render: Option<Arc<dyn Fn() + Send + Sync>>,
314    ) {
315        if let Ok(mut state) = self.state.lock() {
316            state.content_height = content_height;
317            state.viewport_height = viewport_height;
318
319            // Store the render callback
320            if let Some(cb) = request_render {
321                *self.request_render_callback.lock().unwrap() = Some(cb);
322            }
323
324            // If following end, adjust scroll position
325            if self.options.follow == FollowMode::End && state.is_following_end {
326                let max_scroll = content_height.saturating_sub(viewport_height);
327                state.scroll_top = max_scroll;
328            }
329
330            // Ensure scroll position is valid
331            let max_scroll = content_height.saturating_sub(viewport_height);
332            state.scroll_top = state.scroll_top.min(max_scroll);
333
334            // Update follow state
335            if state.scroll_top < max_scroll {
336                state.follow_suppressed_at_end = false;
337            }
338            if self.options.follow == FollowMode::End
339                && state.scroll_top == max_scroll
340                && !state.follow_suppressed_at_end
341            {
342                state.is_following_end = true;
343            }
344
345            // Hide scrollbar if content fits
346            if content_height <= viewport_height {
347                state.transient_scrollbar_visible = false;
348            }
349        }
350    }
351
352    /// Request a render from the layout system.
353    fn request_render(&self) {
354        if let Ok(cb) = self.request_render_callback.lock() {
355            if let Some(callback) = cb.as_ref() {
356                callback();
357            }
358        }
359    }
360
361    /// Render fresh child content, applying scroll for a fixed viewport.
362    ///
363    /// Normal UI updates use this path so streamed text and mutable tool
364    /// components are reflected immediately. The result also primes the
365    /// cache used by subsequent pure scroll frames.
366    pub fn render_with_viewport(&self, width: usize, height: usize) -> Vec<String> {
367        self.render_with_viewport_impl(width, height, false)
368    }
369
370    /// Apply the viewport to the last rendered child content when possible.
371    /// A width change automatically falls back to a fresh render.
372    pub fn render_with_cached_content(&self, width: usize, height: usize) -> Vec<String> {
373        self.render_with_viewport_impl(width, height, true)
374    }
375
376    fn render_with_viewport_impl(
377        &self,
378        width: usize,
379        height: usize,
380        reuse_cached_content: bool,
381    ) -> Vec<String> {
382        let content_width = self.get_content_width(width);
383        let cached = if reuse_cached_content {
384            self.render_cache
385                .lock()
386                .ok()
387                .and_then(|cache| cache.clone())
388                .filter(|cache| cache.content_width == content_width)
389        } else {
390            None
391        };
392        let all_lines = cached.map(|cache| cache.lines).unwrap_or_else(|| {
393            let lines = Arc::new(self.child.render(content_width));
394            if let Ok(mut cache) = self.render_cache.lock() {
395                *cache = Some(ScrollRenderCache {
396                    content_width,
397                    lines: lines.clone(),
398                });
399            }
400            lines
401        });
402        let content_height = all_lines.len();
403
404        // Update state
405        self.update_layout(content_height, height, None);
406
407        let scroll_top = self.scroll_top();
408
409        // Extract visible portion
410        let visible: Vec<String> = all_lines
411            .iter()
412            .skip(scroll_top)
413            .take(height)
414            .cloned()
415            .collect();
416
417        // Pad to fill viewport if needed
418        let mut visible = visible;
419        while visible.len() < height {
420            visible.push(String::new());
421        }
422
423        // Scrollbar column: a positioned thumb on the right edge so scrolling
424        // has visible feedback (the old code was a TODO no-op — scrolling
425        // moved the window with no indicator). `Always` reserves the column in
426        // get_content_width; `Auto` overlays it while a scroll is active.
427        if self.is_scrollbar_visible() {
428            let total = content_height.max(1);
429            let viewport = height.max(1);
430            let thumb = if total <= viewport {
431                viewport
432            } else {
433                (viewport * viewport / total).max(1)
434            };
435            let thumb_top = if total > viewport {
436                scroll_top * (viewport.saturating_sub(thumb)) / (total - viewport)
437            } else {
438                0
439            };
440            for (i, line) in visible.iter_mut().enumerate() {
441                let on = i >= thumb_top && i < thumb_top + thumb;
442                if width > 0 && !line.contains("\x1b_G") && !line.contains("\x1b]1337;File=") {
443                    let cell = if on {
444                        self.scrollbar_style(" ")
445                    } else {
446                        " ".to_owned()
447                    };
448                    *line = crate::layout::composite_tui_line(line, &cell, width - 1, 1, width);
449                }
450            }
451        }
452
453        visible
454    }
455
456    /// Get the scrollbar style (can be customized).
457    pub fn scrollbar_style(&self, text: &str) -> String {
458        format!("\x1b[100m{}\x1b[49m", text)
459    }
460}
461
462impl Component for ScrollView {
463    fn render(&self, width: usize) -> Vec<String> {
464        // Unconstrained render: show all content
465        let content_width = self.get_content_width(width);
466        self.child.render(content_width)
467    }
468
469    fn invalidate(&self) {
470        if let Ok(mut cache) = self.render_cache.lock() {
471            *cache = None;
472        }
473        self.child.invalidate();
474    }
475
476    fn as_any(&self) -> &dyn Any {
477        self
478    }
479}
480
481impl LayoutNodeProvider for ScrollView {
482    fn layout_node(&self) -> Option<LayoutNode> {
483        Some(LayoutNode::Scroll(ScrollNode::new(self.child.clone())))
484    }
485}
486
487/// Render a ScrollView with a fixed viewport height.
488pub fn render_scroll_view(scroll_view: &ScrollView, width: usize, height: usize) -> Vec<String> {
489    scroll_view.render_with_viewport(width, height)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::Text;
496
497    #[test]
498    fn test_scroll_view_basic() {
499        let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
500        let scroll = ScrollView::simple(text);
501
502        assert_eq!(scroll.scroll_top(), 0);
503        assert!(scroll.is_following_end());
504    }
505
506    #[test]
507    fn test_scroll_by() {
508        let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
509        let scroll = ScrollView::new(
510            text,
511            ScrollViewOptions {
512                follow: FollowMode::None,
513                ..Default::default()
514            },
515        );
516
517        // Simulate content and viewport
518        scroll.update_layout(5, 2, None);
519
520        let unused = scroll.scroll_by(1);
521        assert_eq!(unused, 0);
522        assert_eq!(scroll.scroll_top(), 1);
523    }
524
525    #[test]
526    fn test_scroll_to_end() {
527        let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
528        let scroll = ScrollView::new(
529            text,
530            ScrollViewOptions {
531                follow: FollowMode::End,
532                ..Default::default()
533            },
534        );
535
536        scroll.update_layout(3, 1, None);
537        scroll.scroll_to_end();
538
539        assert!(scroll.is_following_end());
540        assert_eq!(scroll.scroll_top(), 2);
541    }
542
543    #[test]
544    fn test_scroll_to_start() {
545        let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
546        let scroll = ScrollView::simple(text);
547
548        scroll.update_layout(3, 1, None);
549        scroll.scroll_by(2);
550        scroll.scroll_to_start();
551
552        assert_eq!(scroll.scroll_top(), 0);
553        assert!(!scroll.is_following_end());
554    }
555}
556#[cfg(test)]
557mod scroll_tests {
558    use super::*;
559    use crate::Container;
560    use crate::Text;
561
562    fn tall_scroll() -> (Arc<ScrollView>, Arc<Container>) {
563        let inner = Arc::new(Container::new());
564        for i in 0..50 {
565            inner.add_child(Arc::new(Text::new(format!("line {i:02}"), 0, 0)));
566        }
567        let sv = Arc::new(ScrollView::new(
568            inner.clone(),
569            ScrollViewOptions {
570                follow: FollowMode::End,
571                primary: true,
572                ..Default::default()
573            },
574        ));
575        (sv, inner)
576    }
577
578    #[test]
579    fn scroll_by_changes_visible_window() {
580        let (sv, _inner) = tall_scroll();
581        let before = sv.render_with_viewport(40, 10);
582        assert!(
583            before[0].contains("line 40"),
584            "following end shows tail: {:?}",
585            before[0]
586        );
587        // Scroll up 10 lines.
588        let unused = sv.scroll_by(-10);
589        assert_eq!(unused, 0);
590        let after = sv.render_with_viewport(40, 10);
591        assert!(
592            after[0].contains("line 30"),
593            "scroll up should reveal older lines, got {:?}",
594            after[0]
595        );
596        // Scroll back down to the end.
597        sv.scroll_by(10);
598        let back = sv.render_with_viewport(40, 10);
599        assert!(back[0].contains("line 40"));
600    }
601
602    #[test]
603    fn manual_scroll_up_stays_put_until_user_returns_to_end() {
604        let (sv, inner) = tall_scroll();
605        sv.render_with_viewport(40, 10); // sync layout
606        assert!(sv.is_following_end());
607        sv.scroll_by(-3);
608        assert!(!sv.is_following_end(), "manual scroll leaves follow mode");
609
610        let position = sv.scroll_top();
611        inner.add_child(Arc::new(Text::new("new output", 0, 0)));
612        sv.render_with_viewport(40, 10);
613        assert_eq!(
614            sv.scroll_top(),
615            position,
616            "new output must not steal the viewport"
617        );
618        assert!(!sv.is_following_end());
619
620        sv.scroll_to_end();
621        assert!(sv.is_following_end());
622        let previous_end = sv.scroll_top();
623        inner.add_child(Arc::new(Text::new("more output", 0, 0)));
624        sv.render_with_viewport(40, 10);
625        assert!(
626            sv.scroll_top() > previous_end,
627            "tail should advance after follow is restored"
628        );
629    }
630
631    #[test]
632    fn scroll_to_start_keeps_follow_when_content_fits() {
633        let text = Arc::new(Text::new("short", 0, 0));
634        let sv = ScrollView::simple(text);
635        sv.update_layout(1, 10, None);
636
637        sv.scroll_to_start();
638
639        assert!(sv.is_following_end());
640    }
641}