Skip to main content

lv_tui/widgets/
scroll.rs

1use crate::component::{Component, EventCx, LayoutCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Pos, Rect, Size};
4use crate::layout::Constraint;
5use crate::node::Node;
6use crate::render::RenderCx;
7use crate::style::{Color, Style};
8
9/// A scrollable container — content can be larger than the viewport.
10///
11/// Supports vertical and horizontal scrolling via keyboard (Up/Down/Left/Right)
12/// and mouse wheel. Renders a scrollbar indicator when content overflows.
13pub struct Scroll {
14    child: Option<Node>,
15    scroll_y: u16,
16    scroll_x: u16,
17    content_height: u16,
18    content_width: u16,
19}
20
21impl Scroll {
22    pub fn new(child: impl Component + 'static) -> Self {
23        Self {
24            child: Some(Node::new(child)),
25            scroll_y: 0,
26            scroll_x: 0,
27            content_height: 0,
28            content_width: 0,
29        }
30    }
31}
32
33impl Component for Scroll {
34    fn render(&self, cx: &mut RenderCx) {
35        if let Some(child) = &self.child {
36            let viewport = cx.rect;
37
38            // Clamp scroll to valid range
39            let max_scroll_y = self.content_height.saturating_sub(viewport.height);
40            let max_scroll_x = self.content_width.saturating_sub(viewport.width);
41            let sy = self.scroll_y.min(max_scroll_y);
42            let sx = self.scroll_x.min(max_scroll_x);
43
44            // Offset all descendant rects (wrapping to avoid saturation asymmetry)
45            fn offset_all(node: &Node, dx: i16, dy: i16) {
46                let mut r = node.rect();
47                if dx >= 0 { r.x = r.x.wrapping_add(dx as u16); }
48                else { r.x = r.x.wrapping_sub((-dx) as u16); }
49                if dy >= 0 { r.y = r.y.wrapping_add(dy as u16); }
50                else { r.y = r.y.wrapping_sub((-dy) as u16); }
51                node.set_rect(r);
52                node.component.for_each_child(&mut |c: &Node| offset_all(c, dx, dy));
53            }
54
55            offset_all(child, -(sx as i16), -(sy as i16));
56            child.render_with_clip(cx.buffer, cx.focused_id, Some(viewport));
57            offset_all(child, sx as i16, sy as i16);
58
59            // Scrollbar indicator
60            self.render_scrollbar(cx, viewport, sy, max_scroll_y, sx, max_scroll_x);
61        }
62    }
63
64    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
65        Size { width: constraint.max.width, height: constraint.max.height }
66    }
67
68    fn focusable(&self) -> bool { false }
69
70    fn event(&mut self, event: &Event, cx: &mut EventCx) {
71        if matches!(event, Event::Focus | Event::Blur) { return; }
72
73        if let Event::Key(key_event) = event {
74            match &key_event.key {
75                crate::event::Key::Up => {
76                    self.scroll_y = self.scroll_y.saturating_sub(1);
77                    cx.invalidate_paint();
78                    return;
79                }
80                crate::event::Key::Down => {
81                    self.scroll_y = self.scroll_y.saturating_add(1);
82                    cx.invalidate_paint();
83                    return;
84                }
85                crate::event::Key::Left => {
86                    self.scroll_x = self.scroll_x.saturating_sub(1);
87                    cx.invalidate_paint();
88                    return;
89                }
90                crate::event::Key::Right => {
91                    self.scroll_x = self.scroll_x.saturating_add(1);
92                    cx.invalidate_paint();
93                    return;
94                }
95                _ => {}
96            }
97        }
98
99        if let Event::Mouse(mouse_event) = event {
100            match mouse_event.kind {
101                crate::event::MouseKind::ScrollUp => {
102                    self.scroll_y = self.scroll_y.saturating_sub(1);
103                    cx.invalidate_paint();
104                    return;
105                }
106                crate::event::MouseKind::ScrollDown => {
107                    self.scroll_y = self.scroll_y.saturating_add(1);
108                    cx.invalidate_paint();
109                    return;
110                }
111                _ => {}
112            }
113        }
114
115        // Forward events to child in capture phase
116        if cx.phase() == crate::event::EventPhase::Capture {
117            if let Some(child) = &mut self.child {
118                let mut child_cx = EventCx::with_task_sender(
119                    &mut child.dirty, cx.global_dirty, cx.quit,
120                    cx.phase, cx.propagation_stopped, cx.task_sender.clone(),
121                );
122                child.component.event(event, &mut child_cx);
123            }
124        }
125    }
126
127    fn layout(&mut self, rect: Rect, _cx: &mut LayoutCx) {
128        if let Some(child) = &mut self.child {
129            let child_size = child.measure(Constraint::loose(65535, 65535));
130            self.content_height = child_size.height;
131            self.content_width = child_size.width;
132            let child_rect = Rect {
133                x: rect.x, y: rect.y,
134                width: child_size.width.max(rect.width),
135                height: child_size.height.max(rect.height),
136            };
137            child.layout(child_rect);
138        }
139    }
140
141    fn for_each_child(&self, f: &mut dyn FnMut(&Node)) {
142        if let Some(child) = &self.child { f(child); }
143    }
144    fn for_each_child_mut(&mut self, f: &mut dyn FnMut(&mut Node)) {
145        if let Some(child) = &mut self.child { f(child); }
146    }
147    fn style(&self) -> Style { Style::default() }
148}
149
150impl Scroll {
151    /// Draws scrollbar indicators on the right and bottom edges.
152    fn render_scrollbar(&self, cx: &mut RenderCx, vp: Rect, sy: u16, max_y: u16, sx: u16, max_x: u16) {
153        let bar_style = Style::default().bg(Color::Gray);
154
155        // Vertical scrollbar (right edge)
156        if max_y > 0 {
157            let thumb_h = ((vp.height as u64 * vp.height as u64) / (vp.height as u64 + max_y as u64)) as u16;
158            let thumb_h = thumb_h.max(1);
159            let thumb_y = if max_y > 0 {
160                vp.y.saturating_add((sy as u64 * (vp.height.saturating_sub(thumb_h)) as u64 / max_y as u64) as u16)
161            } else {
162                vp.y
163            };
164
165            for y in vp.y..vp.y.saturating_add(vp.height) {
166                let x = vp.x.saturating_add(vp.width.saturating_sub(1));
167                if y >= thumb_y && y < thumb_y.saturating_add(thumb_h) {
168                    cx.buffer.write_text(Pos { x, y }, vp, "█", &bar_style);
169                } else if let Some(cell) = cx.buffer.get_mut(x, y) {
170                    cell.style.bg = Some(Color::Black);
171                }
172            }
173        }
174
175        // Horizontal scrollbar (bottom edge)
176        if max_x > 0 {
177            let thumb_w = ((vp.width as u64 * vp.width as u64) / (vp.width as u64 + max_x as u64)) as u16;
178            let thumb_w = thumb_w.max(1);
179            let thumb_x = if max_x > 0 {
180                vp.x.saturating_add((sx as u64 * (vp.width.saturating_sub(thumb_w)) as u64 / max_x as u64) as u16)
181            } else {
182                vp.x
183            };
184
185            for x in vp.x..vp.x.saturating_add(vp.width) {
186                let y = vp.y.saturating_add(vp.height.saturating_sub(1));
187                if x >= thumb_x && x < thumb_x.saturating_add(thumb_w) {
188                    cx.buffer.write_text(Pos { x, y }, vp, "█", &bar_style);
189                } else if let Some(cell) = cx.buffer.get_mut(x, y) {
190                    cell.style.bg = Some(Color::Black);
191                }
192            }
193        }
194    }
195}