scroll_demo/scroll_demo.rs
1//! Scroll Demo
2//!
3//! Demonstrates MinUI's unified scrolling architecture:
4//! - `ScrollBox` provides a scrollable viewport backed by a shared `ScrollState`
5//! - `ScrollBar` binds to the same `ScrollState` for thumb dragging + arrow buttons
6//! - Interaction routing via `UiScene` (wraps `InteractionCache`) + `WidgetArea`
7//! - Scrollbar auto-hide UX via `AutoHide`
8//!
9//! Controls:
10//! - Mouse wheel: vertical scroll (only when over a registered scroll target / or focused)
11//! - Horizontal wheel (if your terminal sends it): horizontal scroll (same routing policy)
12//! - Drag scrollbar thumbs (with app-level mouse capture)
13//! - Click scrollbar arrows
14//! - Arrow keys: scroll (Up/Down/Left/Right)
15//! - Press 'q' to quit
16//!
17//! Notes:
18//! - `ScrollBox` is sized each frame based on terminal size.
19//! - Content is built each frame (no cloning required).
20//! - Scrollbars are auto-hidden unless:
21//! - you recently scrolled, or
22//! - the mouse is near the scrollbar area, or
23//! - you are actively dragging the thumb.
24
25use minui::prelude::*;
26use minui::ui::{AutoHide, RouteTarget, UiScene};
27use minui::widgets::controls::scrollbar::{ScrollBar, ScrollBarOptions, ScrollUnit};
28use minui::widgets::scroll::{ScrollOrientation, ScrollSize, ScrollState};
29use minui::widgets::{WidgetArea, WindowView};
30
31use std::cell::RefCell;
32use std::rc::Rc;
33use std::time::Duration;
34
35const ID_SCROLLBOX: usize = 1;
36const ID_SCROLLBOX_VIEWPORT: usize = 4;
37
38// Scrollbar composite ids (root + thumb/track + arrows)
39const ID_VSCROLL_ROOT: usize = 2;
40const ID_VSCROLL_THUMB: usize = 5;
41const ID_VSCROLL_ARROW_START: usize = 6;
42const ID_VSCROLL_ARROW_END: usize = 7;
43
44const ID_HSCROLL_ROOT: usize = 3;
45const ID_HSCROLL_THUMB: usize = 8;
46const ID_HSCROLL_ARROW_START: usize = 9;
47const ID_HSCROLL_ARROW_END: usize = 10;
48
49// Owner ids for routing composite widgets as a single target
50//
51// IMPORTANT (immediate-mode detail):
52// `UiScene` owner mappings are frame-scoped (cleared in `begin_frame()`), just like interaction
53// registrations. That means you should call `set_owner_for_ids(...)` in the same frames where the
54// corresponding ids are registered (e.g. only when a scrollbar is visible and registered).
55const OWNER_VSCROLL: usize = 1;
56const OWNER_HSCROLL: usize = 2;
57
58struct ScrollDemoState {
59 ui: UiScene,
60 scroll: Rc<RefCell<ScrollState>>,
61 vbar: ScrollBar,
62 hbar: ScrollBar,
63
64 // Auto-hide behavior for scrollbars.
65 // NOTE: This is app-level policy (Phase 1), not owned by the widgets.
66 autohide: AutoHide,
67}
68
69fn main() -> minui::Result<()> {
70 // Shared scroll model (content size + viewport size + offsets).
71 // Seed with non-zero sizes so scrollbar math is sane before first draw.
72 let scroll = Rc::new(RefCell::new(ScrollState::new(
73 ScrollSize::new(1, 1),
74 ScrollSize::new(1, 1),
75 )));
76
77 // Initial scrollbars; sizes are refreshed every draw frame.
78 let vbar = ScrollBar::new(
79 1,
80 1,
81 Rc::clone(&scroll),
82 ScrollBarOptions {
83 orientation: ScrollOrientation::Vertical,
84 show_arrows: true,
85 scroll_step: Some(1),
86 ..ScrollBarOptions::default()
87 },
88 );
89
90 let hbar = ScrollBar::new(
91 1,
92 1,
93 Rc::clone(&scroll),
94 ScrollBarOptions {
95 orientation: ScrollOrientation::Horizontal,
96 show_arrows: true,
97 scroll_step: Some(2),
98 ..ScrollBarOptions::default()
99 },
100 );
101
102 let initial = ScrollDemoState {
103 ui: UiScene::new(),
104 scroll,
105 vbar,
106 hbar,
107 autohide: AutoHide::new(Duration::from_millis(900), 2),
108 };
109
110 let mut app = App::new(initial)?;
111
112 app.run(
113 // ============================
114 // Update: route events
115 // ============================
116 |state, event| {
117 // Apply common scene policies:
118 // - observe mouse position
119 // - tab traversal (if any focusables are registered this frame)
120 // - click-to-focus + mouse capture
121 let _effects = state.ui.apply_policies(&event);
122
123 // Quit (prefer modifier-aware events, with legacy fallback)
124 if let Event::KeyWithModifiers(k) = event {
125 if matches!(k.key, KeyKind::Char('q')) {
126 return false;
127 }
128 }
129 if matches!(event, Event::Character('q')) {
130 return false;
131 }
132
133 // Mouse wheel scrolling:
134 // Route ONLY to the scroll target under cursor (preferred) or the focused scroll target.
135 // This is now centralized in UiScene.
136 match event {
137 Event::MouseScroll { delta } => {
138 if let Some(RouteTarget::Id(_id)) = state.ui.route_wheel_event(&event) {
139 state.autohide.mark_activity();
140
141 // Convention: delta > 0 scrolls up, delta < 0 scrolls down.
142 // We invert to translate "scroll down" into positive offset movement.
143 let dy: i16 = -(delta as i16);
144 state
145 .scroll
146 .borrow_mut()
147 .scroll_by(ScrollOrientation::Vertical, dy);
148 }
149 }
150 Event::MouseScrollHorizontal { delta } => {
151 if let Some(RouteTarget::Id(_id)) = state.ui.route_wheel_event(&event) {
152 state.autohide.mark_activity();
153
154 let dx: i16 = -(delta as i16);
155 state
156 .scroll
157 .borrow_mut()
158 .scroll_by(ScrollOrientation::Horizontal, dx);
159 }
160 }
161 _ => {}
162 }
163
164 // Keyboard scrolling (prefer modifier-aware events, with legacy fallback).
165 match event {
166 Event::KeyWithModifiers(k) => match k.key {
167 KeyKind::Up => state
168 .vbar
169 .scroll_by_fraction(-1.0 / 5.0, ScrollUnit::Viewport),
170 KeyKind::Down => state
171 .vbar
172 .scroll_by_fraction(1.0 / 5.0, ScrollUnit::Viewport),
173 KeyKind::Left => state
174 .hbar
175 .scroll_by_fraction(-1.0 / 5.0, ScrollUnit::Viewport),
176 KeyKind::Right => state
177 .hbar
178 .scroll_by_fraction(1.0 / 5.0, ScrollUnit::Viewport),
179 _ => {}
180 },
181
182 // Legacy fallback.
183 Event::KeyUp => state
184 .vbar
185 .scroll_by_fraction(-1.0 / 5.0, ScrollUnit::Viewport),
186 Event::KeyDown => state
187 .vbar
188 .scroll_by_fraction(1.0 / 5.0, ScrollUnit::Viewport),
189 Event::KeyLeft => state
190 .hbar
191 .scroll_by_fraction(-1.0 / 5.0, ScrollUnit::Viewport),
192 Event::KeyRight => state
193 .hbar
194 .scroll_by_fraction(1.0 / 5.0, ScrollUnit::Viewport),
195
196 _ => {}
197 }
198
199 // Route pointer events into scrollbars (click/drag/arrows), using UiScene owner grouping.
200 //
201 // IMPORTANT:
202 // - Only route if the relevant areas were registered (i.e. visible).
203 // - If capture is active, UiScene ensures drag/release continue to target the captured id.
204 //
205 // We route to an OWNER so the app can forward to the owning ScrollBar instance.
206 if let Some(target) = state.ui.route_mouse_event_to_owner(&event) {
207 match target {
208 RouteTarget::Owner(OWNER_VSCROLL) => {
209 if let Some(entry) = state.ui.get(ID_VSCROLL_ROOT) {
210 let _ = state.vbar.handle_event(&event, entry.area);
211 }
212 }
213 RouteTarget::Owner(OWNER_HSCROLL) => {
214 if let Some(entry) = state.ui.get(ID_HSCROLL_ROOT) {
215 let _ = state.hbar.handle_event(&event, entry.area);
216 }
217 }
218 _ => {}
219 }
220 }
221
222 true
223 },
224 // ============================
225 // Draw: register areas + draw
226 // ============================
227 |state, window| {
228 state.ui.begin_frame();
229
230 let (term_w, term_h) = window.get_size();
231
232 // Layout constants
233 let margin: u16 = 1;
234 let header_h: u16 = 1;
235
236 // Scrollbar sizes (vbar on the right, hbar on bottom)
237 let vbar_w: u16 = 1;
238 let hbar_h: u16 = 1;
239
240 // ScrollBox styling affects its *outer* size vs the *inner* content viewport.
241 // Because the ScrollBox draws a border + padding, we must reserve extra space so
242 // the right/bottom borders don't get overwritten by scrollbars.
243 let box_border: u16 = 1; // single-line border thickness
244 let box_padding: u16 = 1; // we set ContainerPadding::uniform(1) below
245
246 // Compute a safe outer rect for the ScrollBox (what you pass to with_position_and_size)
247 // leaving room for:
248 // - header line
249 // - scrollbars (drawn OUTSIDE the ScrollBox frame)
250 //
251 // IMPORTANT:
252 // - The ScrollBox outer rect must be fully within the terminal bounds.
253 // - The scrollbars are then placed at `outer_x + outer_w` (right) and `outer_y + outer_h` (bottom),
254 // so we must reserve 1 extra column/row for them as well.
255 let outer_x = margin;
256 let outer_y = margin + header_h;
257
258 // Total insets that reduce the usable inner content area.
259 let inner_inset_x = box_border + box_padding;
260 let inner_inset_y = box_border + box_padding;
261
262 // Available space inside terminal margins for the ScrollBox outer rect.
263 // We reserve the scrollbar column/row OUTSIDE the ScrollBox.
264 let max_outer_w = term_w.saturating_sub(margin * 2).saturating_sub(vbar_w);
265 let max_outer_h = term_h
266 .saturating_sub(margin * 2)
267 .saturating_sub(header_h)
268 .saturating_sub(hbar_h);
269
270 // Clamp outer size to available space. Do NOT force a minimum that can exceed terminal bounds.
271 // If the terminal is too small, we'll end up with a very small box (and the scrollbars may be skipped).
272 let outer_w = max_outer_w;
273 let outer_h = max_outer_h;
274
275 // Inner viewport (content area) starts after border+padding.
276 // NOTE: these are currently unused in this example, but kept for clarity.
277 let viewport_x = outer_x + inner_inset_x;
278 let viewport_y = outer_y + inner_inset_y;
279 let viewport_w = outer_w.saturating_sub(inner_inset_x * 2);
280 let viewport_h = outer_h.saturating_sub(inner_inset_y * 2);
281
282 let _ = (viewport_x, viewport_y, viewport_w, viewport_h);
283
284 // Header / help line
285 window.write_str(
286 margin,
287 margin,
288 "Scroll demo: wheel/drag scrollbars • arrows/keys work • press 'q' to quit",
289 )?;
290
291 // Build content each frame (no cloning needed).
292 // Use a row gap to exercise gap-aware content measurement in ScrollBox.
293 let mut content = Container::vertical().with_row_gap(Gap::Pixels(1));
294
295 // Lots of lines (taller than viewport).
296 for i in 0..150u16 {
297 content = content.add_child(
298 Label::new(format!(
299 "Line {:03} | The quick brown fox jumps over the lazy dog. {}",
300 i,
301 if i % 5 == 0 { "[drag the thumb]" } else { "" }
302 ))
303 .with_text_color(Color::White),
304 );
305 }
306
307 // ScrollBox bound to shared state and sized to viewport.
308 let scrollbox = ScrollBox::both()
309 .with_state(Rc::clone(&state.scroll))
310 // IMPORTANT: pass the OUTER rect (includes border/padding), not the inner viewport.
311 .with_position_and_size(outer_x, outer_y, outer_w, outer_h)
312 .with_border()
313 .with_border_chars(BorderChars::single_line())
314 .with_title("Scrollable content")
315 .with_title_alignment(TitleAlignment::Left)
316 .with_padding(minui::widgets::ContainerPadding::uniform(1))
317 .with_row_gap(Gap::Pixels(1))
318 .add_child(content);
319
320 // Register scrollbox interaction regions:
321 // - outer frame as focusable (panel/split-friendly)
322 // - inner viewport as scrollable (wheel routing)
323 let outer_area = WidgetArea::new(outer_x, outer_y, outer_w, outer_h);
324 scrollbox.register_with_ids(
325 state.ui.cache_mut(),
326 outer_area,
327 ID_SCROLLBOX,
328 ID_SCROLLBOX_VIEWPORT,
329 );
330
331 // Draw scrollbox (updates ScrollState sizes + applies offsets via WindowView).
332 scrollbox.draw(window)?;
333
334 // Vertical scrollbar area (right side)
335 // Place it just outside the ScrollBox's outer border so it doesn't overwrite the frame.
336 // This must still be inside the terminal bounds (x < term_w).
337 let vbar_x = outer_x + outer_w;
338 let vbar_y = outer_y;
339 let vbar_h = outer_h;
340
341 // If there isn't room for the scrollbar column (e.g. tiny terminals), just skip drawing it.
342 if vbar_x >= term_w {
343 return Ok(());
344 }
345
346 // Resize + sync existing scrollbar instead of recreating it every frame.
347 // Recreating would reset drag state, making thumb dragging feel broken.
348 state.vbar.set_size(1, vbar_h);
349 state.vbar.set_show_arrows(true);
350 state.vbar.set_scroll_step(Some(1));
351 state.vbar.sync_from_state_and_resize_parts();
352
353 let v_area = WidgetArea::new(vbar_x, vbar_y, 1, vbar_h);
354
355 // Auto-hide policy: show if recently scrolled, mouse is near, or currently dragging.
356 // Reveal policy:
357 // - show when recently scrolled
358 // - show when mouse is near the scrollbar area
359 // - show when mouse is near the scrollbox RIGHT edge strip (even if the bar itself is hidden)
360 // - show while dragging
361 let mouse_pos = state.ui.last_mouse_pos();
362
363 // Only consider proximity to a thin strip along the scrollbox's right edge.
364 // This prevents the scrollbars from staying visible just because the cursor is somewhere
365 // inside/near the scrollbox.
366 let near_scrollbox_right_edge = if let Some((mx, my)) = mouse_pos {
367 // A 1-column strip at the right edge of the scrollbox.
368 let edge_x = outer_x + outer_w.saturating_sub(1);
369 let edge_strip = WidgetArea::new(edge_x, outer_y, 1, outer_h);
370 state.autohide.is_point_near_area(mx, my, edge_strip)
371 } else {
372 false
373 };
374
375 let show_vbar = state
376 .autohide
377 .should_show(v_area, mouse_pos, state.vbar.is_dragging())
378 || near_scrollbox_right_edge;
379
380 if show_vbar {
381 // Register sub-areas so routing can distinguish thumb vs arrows.
382 // Root is scrollable (hover target), thumb/track is draggable, arrows are focusable.
383 // Register areas into the underlying cache (UiScene wraps InteractionCache).
384 state.vbar.register_with_ids(
385 state.ui.cache_mut(),
386 v_area,
387 ID_VSCROLL_ROOT,
388 ID_VSCROLL_THUMB,
389 Some(ID_VSCROLL_ARROW_START),
390 Some(ID_VSCROLL_ARROW_END),
391 );
392
393 // Group all scrollbar sub-ids under a single owner for routing.
394 state.ui.set_owner_for_ids(
395 OWNER_VSCROLL,
396 &[
397 ID_VSCROLL_ROOT,
398 ID_VSCROLL_THUMB,
399 ID_VSCROLL_ARROW_START,
400 ID_VSCROLL_ARROW_END,
401 ],
402 );
403
404 {
405 let mut view = WindowView {
406 window,
407 x_offset: vbar_x,
408 y_offset: vbar_y,
409 scroll_x: 0,
410 scroll_y: 0,
411 width: 1,
412 height: vbar_h,
413 };
414 state.vbar.draw(&mut view)?;
415 }
416 }
417
418 // Horizontal scrollbar area (bottom)
419 // Place it just outside the ScrollBox's outer border so it doesn't overwrite the frame.
420 // This must still be inside the terminal bounds (y < term_h).
421 let hbar_x = outer_x;
422 let hbar_y = outer_y + outer_h;
423 let hbar_w = outer_w;
424
425 // If there isn't room for the scrollbar row (e.g. tiny terminals), just skip drawing it.
426 if hbar_y >= term_h {
427 return Ok(());
428 }
429
430 // Resize + sync existing scrollbar instead of recreating it every frame.
431 // Recreating would reset drag state, making thumb dragging feel broken.
432 state.hbar.set_size(hbar_w, 1);
433 state.hbar.set_show_arrows(true);
434 state.hbar.set_scroll_step(Some(2));
435 state.hbar.sync_from_state_and_resize_parts();
436
437 let h_area = WidgetArea::new(hbar_x, hbar_y, hbar_w, 1);
438
439 // Auto-hide policy: show if recently scrolled, mouse is near, or currently dragging.
440 // Reveal policy:
441 // - show when recently scrolled
442 // - show when mouse is near the scrollbar area
443 // - show when mouse is near the scrollbox BOTTOM edge strip (even if the bar itself is hidden)
444 // - show while dragging
445 let mouse_pos = state.ui.last_mouse_pos();
446
447 // Only consider proximity to a thin strip along the scrollbox's bottom edge.
448 // This prevents the scrollbars from staying visible just because the cursor is somewhere
449 // inside/near the scrollbox.
450 let near_scrollbox_bottom_edge = if let Some((mx, my)) = mouse_pos {
451 // A 1-row strip at the bottom edge of the scrollbox.
452 let edge_y = outer_y + outer_h.saturating_sub(1);
453 let edge_strip = WidgetArea::new(outer_x, edge_y, outer_w, 1);
454 state.autohide.is_point_near_area(mx, my, edge_strip)
455 } else {
456 false
457 };
458
459 let show_hbar = state
460 .autohide
461 .should_show(h_area, mouse_pos, state.hbar.is_dragging())
462 || near_scrollbox_bottom_edge;
463
464 if show_hbar {
465 // Register sub-areas so routing can distinguish thumb vs arrows.
466 // Root is scrollable (hover target), thumb/track is draggable, arrows are focusable.
467 // Register areas into the underlying cache (UiScene wraps InteractionCache).
468 state.hbar.register_with_ids(
469 state.ui.cache_mut(),
470 h_area,
471 ID_HSCROLL_ROOT,
472 ID_HSCROLL_THUMB,
473 Some(ID_HSCROLL_ARROW_START),
474 Some(ID_HSCROLL_ARROW_END),
475 );
476
477 // Group all scrollbar sub-ids under a single owner for routing.
478 state.ui.set_owner_for_ids(
479 OWNER_HSCROLL,
480 &[
481 ID_HSCROLL_ROOT,
482 ID_HSCROLL_THUMB,
483 ID_HSCROLL_ARROW_START,
484 ID_HSCROLL_ARROW_END,
485 ],
486 );
487
488 {
489 let mut view = WindowView {
490 window,
491 x_offset: hbar_x,
492 y_offset: hbar_y,
493 scroll_x: 0,
494 scroll_y: 0,
495 width: hbar_w,
496 height: 1,
497 };
498 state.hbar.draw(&mut view)?;
499 }
500 }
501
502 window.end_frame()?;
503 Ok(())
504 },
505 )?;
506
507 Ok(())
508}