1use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget, WidgetId};
7
8use super::rows::WHEEL_ROWS;
9use super::scrollbar::{self, ScrollMetrics, ScrollbarStyle};
10
11pub struct ScrollView<Msg> {
17 content: Vec<Node<Msg>>,
18 scrollbar: Option<ScrollbarStyle>,
19}
20
21#[derive(Debug, Default)]
22struct ScrollMemory {
23 offset: u16,
24 content_height: u16,
25 revealed: Option<WidgetId>,
26 dragging: bool,
27}
28
29impl<Msg: 'static> ScrollView<Msg> {
30 #[must_use]
32 pub fn new() -> Self {
33 Self { content: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)], scrollbar: None }
34 }
35
36 #[must_use]
38 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
39 self.scrollbar = Some(style);
40 self
41 }
42
43 fn metrics(memory: &ScrollMemory, area: Rect) -> ScrollMetrics {
44 ScrollMetrics {
45 total: usize::from(memory.content_height),
46 visible: usize::from(area.height),
47 offset: usize::from(memory.offset),
48 }
49 }
50
51 fn scroll_to(cx: &mut EventCx<'_, Msg>, offset: i32) {
52 let area = cx.area();
53 let memory = cx.memory::<ScrollMemory>();
54 let max = memory.content_height.saturating_sub(area.height);
55 memory.offset = clamp_u16(offset).min(max);
56 }
57}
58
59impl<Msg: 'static> Default for ScrollView<Msg> {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl<Msg: 'static> Container<Msg> for ScrollView<Msg> {
66 fn set_children(&mut self, children: Vec<Node<Msg>>) {
67 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
68 column.layout.width = Length::Fill(1);
69 self.content = vec![column];
70 }
71}
72
73impl<Msg: 'static> Widget<Msg> for ScrollView<Msg> {
74 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
75 let content = self.content.first().map_or(Size::default(), |c| cx.measure_child(c, available));
76 Size::new(content.width.saturating_add(1), content.height).min(available)
77 }
78
79 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
80 let Some(content) = self.content.first() else {
81 return;
82 };
83 cx.register_hit(area);
84 let full = cx.measure_child(content, Size::new(area.width, u16::MAX)).height;
85 let overflows = full > area.height;
86 let width = if overflows { area.width.saturating_sub(2) } else { area.width };
87 let height = if overflows { cx.measure_child(content, Size::new(width, u16::MAX)).height } else { full };
88 let offset = {
89 let memory = cx.memory::<ScrollMemory>();
90 memory.content_height = height;
91 memory.offset = memory.offset.min(height.saturating_sub(area.height));
92 memory.offset
93 };
94 let content_rect = Rect::new(area.x, area.y - i32::from(offset), width, height);
95 cx.with_clip(area, |cx| cx.paint_child(content, content_rect));
96
97 if let Some(focused) = cx.interaction.focused
98 && focused != cx.id()
99 && cx.frame.is_within(focused, cx.id())
100 && let Some(rect) = cx.frame.rects.get(&focused).copied()
101 && cx.memory::<ScrollMemory>().revealed != Some(focused)
102 {
103 let memory = cx.memory::<ScrollMemory>();
104 memory.revealed = Some(focused);
105 let top = rect.y - content_rect.y;
106 let bottom = top + i32::from(rect.height);
107 let new_offset = if top < i32::from(offset) {
108 clamp_u16(top)
109 } else if bottom > i32::from(offset) + i32::from(area.height) {
110 clamp_u16(bottom - i32::from(area.height))
111 } else {
112 offset
113 };
114 if new_offset != offset {
115 memory.offset = new_offset;
116 cx.request_frame_in(std::time::Duration::ZERO);
117 }
118 }
119
120 if overflows {
121 let metrics = Self::metrics(cx.memory::<ScrollMemory>(), area);
122 let active =
123 cx.memory::<ScrollMemory>().dragging || cx.pointer().is_some_and(|(x, _)| x >= area.right() - 1);
124 scrollbar::paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, active, self.scrollbar);
125 }
126 }
127
128 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
129 let area = cx.area();
130 let (offset, metrics) = {
131 let memory = cx.memory::<ScrollMemory>();
132 (i32::from(memory.offset), Self::metrics(memory, area))
133 };
134 let page = i32::from(area.height.saturating_sub(1).max(1));
135 match event {
136 Event::Key(key) => {
137 let target = if key.is_plain(Key::Up) {
138 offset - 1
139 } else if key.is_plain(Key::Down) {
140 offset + 1
141 } else if key.is_plain(Key::PageUp) {
142 offset - page
143 } else if key.is_plain(Key::PageDown) {
144 offset + page
145 } else if key.is_plain(Key::Home) {
146 0
147 } else if key.is_plain(Key::End) {
148 i32::MAX
149 } else {
150 return false;
151 };
152 if !metrics.overflows() {
153 return false;
154 }
155 Self::scroll_to(cx, target);
156 true
157 }
158 Event::Mouse(mouse) => {
159 let on_bar = metrics.overflows() && mouse.x == area.right() - 1;
160 match mouse.kind {
161 MouseKind::ScrollUp if metrics.overflows() => {
162 Self::scroll_to(cx, offset - i32::from(WHEEL_ROWS));
163 true
164 }
165 MouseKind::ScrollDown if metrics.overflows() => {
166 Self::scroll_to(cx, offset + i32::from(WHEEL_ROWS));
167 true
168 }
169 MouseKind::Down(MouseButton::Left) if on_bar => {
170 cx.capture_pointer();
171 cx.memory::<ScrollMemory>().dragging = true;
172 let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
173 Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
174 true
175 }
176 MouseKind::Drag(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
177 let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
178 Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
179 true
180 }
181 MouseKind::Up(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
182 cx.memory::<ScrollMemory>().dragging = false;
183 true
184 }
185 _ => false,
186 }
187 }
188 _ => false,
189 }
190 }
191
192 fn focusable(&self) -> bool {
193 true
194 }
195
196 fn children(&self) -> &[Node<Msg>] {
197 &self.content
198 }
199
200 fn children_mut(&mut self) -> &mut [Node<Msg>] {
201 &mut self.content
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::runtime::{App, Command, Harness};
209 use crate::widget::View;
210 use crate::widgets::{Button, Text};
211
212 struct Demo;
213
214 impl App for Demo {
215 type Msg = ();
216 fn update(&mut self, _: ()) -> Command<()> {
217 Command::none()
218 }
219 fn view(&self, ui: &mut View<'_, ()>) {
220 ui.add_with(ScrollView::new(), |ui| {
221 for i in 0..20 {
222 ui.add(Text::new(format!("line {i}")));
223 }
224 ui.add(Button::new("Bottom").on_press(())).id("bottom");
225 })
226 .fill();
227 }
228 }
229
230 #[test]
231 fn scrolls_with_keys_and_wheel() {
232 let mut h = Harness::new(Demo, 20, 5);
233 assert!(h.screen().starts_with("line 0"));
234 h.press("tab").press("pgdn");
235 assert!(h.screen().starts_with("line 4"), "{}", h.screen());
236 h.mouse(MouseKind::ScrollDown, 2, 2);
237 assert!(h.screen().starts_with("line 7"));
238 h.press("end");
239 assert!(h.screen().contains("Bottom"));
240 h.press("home");
241 assert!(h.screen().starts_with("line 0"));
242 }
243
244 #[test]
245 fn reveals_focused_widget() {
246 let mut h = Harness::new(Demo, 20, 5);
247 h.press("tab").press("tab");
248 assert!(h.is_focused("bottom"));
249 assert!(h.screen().contains("Bottom"), "{}", h.screen());
250 }
251
252 #[test]
253 fn draws_scrollbar_only_when_needed() {
254 let h = Harness::new(Demo, 20, 30);
256 let muted = h.env().theme().color("muted");
257 assert_ne!(h.bg(19, 0), muted);
258 let short = Harness::new(Demo, 20, 5);
259 assert_eq!(short.bg(19, 0), muted, "the thumb sits at the top");
260 assert_eq!(short.bg(19, 4), short.env().theme().color("raised"), "the track runs below it");
261 }
262
263 struct Pinned(Option<ScrollbarStyle>);
264
265 impl App for Pinned {
266 type Msg = ();
267 fn update(&mut self, _: ()) -> Command<()> {
268 Command::none()
269 }
270 fn view(&self, ui: &mut View<'_, ()>) {
271 let view = self.0.map_or_else(ScrollView::new, |style| ScrollView::new().scrollbar(style));
272 ui.add_with(view, |ui| {
273 for i in 0..20 {
274 ui.add(Text::new(format!("line {i}")));
275 }
276 })
277 .fill();
278 }
279 }
280
281 fn bar(h: &Harness<Pinned>) -> String {
283 h.screen().lines().map(|line| format!("{line:<10}").chars().nth(9).unwrap_or(' ')).collect()
284 }
285
286 #[test]
287 fn every_style_draws_its_own_column() {
288 let expected = [
289 (ScrollbarStyle::Block, " "),
290 (ScrollbarStyle::Half, "▐▕▕▕"),
291 (ScrollbarStyle::Thin, "▕ "),
292 (ScrollbarStyle::Dots, "•···"),
293 ];
294 for (style, column) in expected {
295 let mut h = Harness::new(Pinned(Some(style)), 10, 4);
296 assert_eq!(bar(&h), column, "{style:?}");
297 let theme = h.env().theme();
298 let (raised, muted, canvas) = (theme.color("raised"), theme.color("muted"), theme.color("canvas"));
299 let thumb = if style == ScrollbarStyle::Dots { theme.color("dim") } else { muted };
300 match style {
301 ScrollbarStyle::Block => assert_eq!((h.bg(9, 0), h.bg(9, 3)), (muted, raised)),
302 ScrollbarStyle::Thin => assert_eq!(h.bg(9, 3), canvas, "thin draws no track"),
303 ScrollbarStyle::Half => assert_eq!(h.fg(9, 0), muted),
304 ScrollbarStyle::Dots => assert_eq!((h.fg(9, 0), h.fg(9, 3)), (theme.color("dim"), muted)),
305 }
306 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
307 assert!(h.screen().is_ascii(), "{style:?}");
308 assert_eq!(h.bg(9, 0), thumb, "ASCII thumb is a coloured cell in {style:?}");
309 }
310 }
311
312 #[test]
313 fn theme_word_chooses_the_style_and_pinning_wins() {
314 let dir = std::env::temp_dir().join(format!("quvyta-scrollbar-{}", std::process::id()));
315 std::fs::create_dir_all(&dir).expect("temp dir");
316 let theme = "[meta]\nname = \"Dotted\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"dots\"\n";
317 std::fs::write(dir.join("dotted.toml"), theme).expect("theme file");
318 let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
319 let env = crate::env::Env::load(&dirs).expect("loads");
320 let mut h = Harness::with_env(Pinned(None), env.clone(), 10, 4);
321 h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
322 assert_eq!(bar(&h), "•···");
323 let mut pinned = Harness::with_env(Pinned(Some(ScrollbarStyle::Thin)), env, 10, 4);
324 pinned.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
325 assert_eq!(bar(&pinned), "▕ ");
326 std::fs::remove_dir_all(dir).ok();
327 }
328}