piki_gui/responsive_scrollbar.rs
1// Responsive scrollbar with three visibility states: asleep, awake, and hovered
2// Based on FLTK's scrollbar with custom drawing
3
4use fltk::{draw as fltk_draw, enums::*, prelude::*, valuator::Scrollbar};
5use std::cell::RefCell;
6use std::rc::Rc;
7use std::time::{Duration, Instant};
8
9/// Minimum height (in pixels) of the slider thumb.
10///
11/// FLTK derives the *draggable* thumb from `slider_size()`; for a very long note
12/// that fraction is tiny. We floor `slider_size()` (see `min_slider_size`) so the
13/// thumb never drops below this, keeping it comfortably grabbable.
14pub const MIN_THUMB_HEIGHT: i32 = 20;
15
16/// Compute the thumb rectangle (top `y`, height) for the given scroll state.
17///
18/// We drive dragging ourselves (see the `handle` closure), so — unlike FLTK's
19/// built-in `Fl_Scrollbar` — the thumb spans the *full* height with no space
20/// reserved for arrow buttons. Both the drawing and the hit-testing go through
21/// this one function, so what you see is exactly what you grab.
22///
23/// Returns `None` when there is nothing to drag (content fits, or degenerate size).
24fn thumb_geometry(
25 y: i32,
26 h: i32,
27 min: f64,
28 max: f64,
29 val: f64,
30 slider_size: f32,
31) -> Option<(i32, i32)> {
32 if max <= min || slider_size <= 0.0 || h <= 0 {
33 return None;
34 }
35 let s = ((slider_size as f64 * h as f64).round() as i32).clamp(MIN_THUMB_HEIGHT, h);
36 if s >= h {
37 // Content fits within the view; no thumb to draw or drag.
38 return None;
39 }
40 let val_frac = ((val - min) / (max - min)).clamp(0.0, 1.0);
41 let track = h - s;
42 Some((y + (val_frac * track as f64).round() as i32, s))
43}
44
45/// Inverse of `thumb_geometry`'s position mapping: given the desired thumb *top*
46/// (window y), return the scroll value that places it there.
47fn value_for_thumb_top(
48 thumb_top: i32,
49 y: i32,
50 h: i32,
51 thumb_height: i32,
52 min: f64,
53 max: f64,
54) -> f64 {
55 let track = h - thumb_height;
56 if track <= 0 {
57 return min;
58 }
59 let frac = ((thumb_top - y) as f64 / track as f64).clamp(0.0, 1.0);
60 min + frac * (max - min)
61}
62
63/// Value delta for one "page" — a full visible screen — matching the PageUp/PageDown
64/// keys and FLTK's own trough-click step. Since `slider_size` is `visible/content`,
65/// this works out to exactly the visible height in scroll-value units.
66fn page_step(min: f64, max: f64, slider_size: f32) -> f64 {
67 let ss = slider_size as f64;
68 if ss <= 0.0 || ss >= 1.0 {
69 return 0.0;
70 }
71 (max - min) * ss / (1.0 - ss)
72}
73
74/// A page-scroll gesture in progress (mouse held down in the track, above or below
75/// the thumb). We page toward `target_y` (the click position) and stop once the
76/// thumb reaches it, repeating on a timer while the button stays down.
77#[derive(Debug, Clone, Copy)]
78struct Paging {
79 /// `true` = page down (clicked below the thumb), `false` = page up.
80 down: bool,
81 /// Window-y of the click; paging stops once the thumb covers this point.
82 target_y: i32,
83}
84
85/// Perform one page step for an in-progress track-click gesture. Returns `true` if
86/// paging should continue (thumb hasn't yet reached the cursor and isn't at a bound).
87fn apply_page_step(state: &Rc<RefCell<ResponsiveScrollbarState>>, sb: &mut Scrollbar) -> bool {
88 let paging = match state.borrow().paging {
89 Some(p) => p,
90 None => return false,
91 };
92 let (y, h) = (sb.y(), sb.h());
93 let (min, max) = (sb.minimum(), sb.maximum());
94 let slider_size = sb.slider_size();
95 let val = sb.value();
96
97 let (thumb_top, thumb_h) = match thumb_geometry(y, h, min, max, val, slider_size) {
98 Some(g) => g,
99 None => return false,
100 };
101
102 // Stop once the thumb has moved far enough to cover the click point.
103 let reached = if paging.down {
104 paging.target_y < thumb_top + thumb_h
105 } else {
106 paging.target_y >= thumb_top
107 };
108 if reached {
109 return false;
110 }
111
112 let page = page_step(min, max, slider_size);
113 if page <= 0.0 {
114 return false;
115 }
116 let new_val = if paging.down {
117 (val + page).min(max)
118 } else {
119 (val - page).max(min)
120 };
121 if new_val == val {
122 return false; // already at a bound
123 }
124 sb.set_value(new_val);
125 sb.do_callback();
126 sb.redraw();
127 true
128}
129
130/// Visibility state of the responsive scrollbar
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ScrollbarState {
133 /// Only background is rendered
134 Asleep,
135 /// Background + light gray rectangle where slider would be
136 Awake,
137 /// Full scrollbar is rendered (on hover)
138 Hovered,
139}
140
141/// Shared state for the responsive scrollbar
142#[derive(Debug, Clone)]
143struct ResponsiveScrollbarState {
144 state: ScrollbarState,
145 last_wake_time: Instant,
146 background_color: Color,
147 /// While a thumb drag is in progress, the offset (px) from the thumb's top
148 /// to the mouse at grab time. `None` when not dragging.
149 drag_offset: Option<i32>,
150 /// An in-progress track-click page gesture, if any.
151 paging: Option<Paging>,
152 /// Whether the auto-repeat timeout for paging is currently scheduled, so a
153 /// held button doesn't stack up multiple timer chains.
154 paging_timer_active: bool,
155}
156
157/// Responsive scrollbar wrapper
158#[derive(Clone)]
159pub struct ResponsiveScrollbar {
160 scrollbar: Scrollbar,
161 state: Rc<RefCell<ResponsiveScrollbarState>>,
162}
163
164impl ResponsiveScrollbar {
165 /// Create a new responsive scrollbar
166 pub fn new(x: i32, y: i32, w: i32, h: i32, background_color: Color) -> Self {
167 let mut scrollbar = Scrollbar::default().with_pos(x, y).with_size(w, h);
168
169 let state = Rc::new(RefCell::new(ResponsiveScrollbarState {
170 state: ScrollbarState::Asleep,
171 last_wake_time: Instant::now() - Duration::from_secs(10),
172 background_color,
173 drag_offset: None,
174 paging: None,
175 paging_timer_active: false,
176 }));
177
178 // Set up custom draw callback
179 scrollbar.draw({
180 let state = state.clone();
181 let sb = scrollbar.clone();
182 move |_| {
183 let st = state.borrow();
184
185 // Only check for auto-sleep transition here
186 // Don't check mouse position in draw callback - use handle callback for that
187
188 let x = sb.x();
189 let y = sb.y();
190 let w = sb.w();
191 let h = sb.h();
192
193 let rect_col = Color::from_rgb(204, 204, 204); // Light gray for slider rectangle
194
195 match st.state {
196 ScrollbarState::Asleep | ScrollbarState::Awake => {
197 // Draw background
198 fltk_draw::set_draw_color(st.background_color);
199 fltk_draw::draw_rectf(x, y, w, h);
200
201 // Draw light gray rectangle where slider would be
202 // Calculate slider position and size based on scrollbar values
203 let min = sb.minimum();
204 let max = sb.maximum();
205 let val = sb.value();
206 let slider_size = sb.slider_size();
207 let sbw = if st.state == ScrollbarState::Awake {
208 w - 4
209 } else {
210 3
211 };
212 let offset = if st.state == ScrollbarState::Awake {
213 3
214 } else {
215 w - sbw - 2
216 };
217
218 if let Some((slider_y, slider_height)) =
219 thumb_geometry(y, h, min, max, val, slider_size)
220 {
221 // Draw light gray slider rectangle
222 fltk_draw::set_draw_color(rect_col);
223 fltk_draw::draw_rounded_rectf(
224 x + offset,
225 slider_y + 1,
226 sbw,
227 slider_height - 2,
228 1,
229 );
230 }
231 }
232 ScrollbarState::Hovered => {
233 // Draw a proper scrollbar
234 // Draw scrollbar background
235 // fltk_draw::draw_box(
236 // FrameType::FlatBox,
237 // x,
238 // y,
239 // w,
240 // h,
241 // Color::from_rgb(240, 240, 240),
242 // );
243 fltk_draw::set_draw_color(st.background_color);
244 fltk_draw::draw_rectf(x, y, w, h);
245
246 // Calculate slider position and size
247 let min = sb.minimum();
248 let max = sb.maximum();
249 let val = sb.value();
250 let slider_size = sb.slider_size();
251
252 if let Some((slider_y, slider_height)) =
253 thumb_geometry(y, h, min, max, val, slider_size)
254 {
255 // Draw slider with proper 3D look
256 fltk_draw::draw_box(
257 FrameType::ThinUpBox,
258 x + 1,
259 slider_y + 1,
260 w - 2,
261 slider_height - 2,
262 rect_col,
263 );
264 }
265 }
266 }
267 }
268 });
269
270 // Set up handle callback for proper hover detection
271 scrollbar.handle({
272 let state = state.clone();
273 let mut sb = scrollbar.clone();
274 move |_, event| {
275 match event {
276 Event::Enter => {
277 // Mouse entered scrollbar area
278 state.borrow_mut().state = ScrollbarState::Hovered;
279 sb.redraw();
280 true // We handled this event
281 }
282 Event::Leave => {
283 // Mouse left scrollbar area
284 let mut st = state.borrow_mut();
285 st.state = ScrollbarState::Awake;
286 st.last_wake_time = Instant::now();
287 sb.redraw();
288 true
289 }
290 Event::Move => {
291 // Keep updating hovered state while mouse is moving over it
292 let mut st = state.borrow_mut();
293 if st.state != ScrollbarState::Hovered {
294 st.state = ScrollbarState::Hovered;
295 sb.redraw();
296 }
297 true
298 }
299 Event::Push => {
300 // We drive dragging ourselves (see `super_handle_first(false)` below)
301 // so the thumb uses the full height with no arrow-button gap.
302 let ey = fltk::app::event_y();
303 let (y, h) = (sb.y(), sb.h());
304 let geom = thumb_geometry(
305 y,
306 h,
307 sb.minimum(),
308 sb.maximum(),
309 sb.value(),
310 sb.slider_size(),
311 );
312
313 let mut start_paging = false;
314 {
315 let mut st = state.borrow_mut();
316 st.state = ScrollbarState::Hovered;
317 st.drag_offset = None;
318 st.paging = None;
319 if let Some((thumb_top, thumb_h)) = geom {
320 if ey >= thumb_top && ey < thumb_top + thumb_h {
321 // Grabbed the thumb directly.
322 st.drag_offset = Some(ey - thumb_top);
323 } else {
324 // Clicked in the track: page toward the click, repeating
325 // while the button stays down (classic scrollbar paging).
326 st.paging = Some(Paging {
327 down: ey >= thumb_top + thumb_h,
328 target_y: ey,
329 });
330 start_paging = true;
331 }
332 }
333 }
334 if start_paging {
335 // Page once now, then auto-repeat after a short initial delay.
336 let more = apply_page_step(&state, &mut sb);
337 let mut st = state.borrow_mut();
338 if more && !st.paging_timer_active {
339 st.paging_timer_active = true;
340 drop(st);
341 let state_pg = state.clone();
342 let mut sb_pg = sb.clone();
343 fltk::app::add_timeout3(0.3, move |handle| {
344 let cont = apply_page_step(&state_pg, &mut sb_pg);
345 if cont && state_pg.borrow().paging.is_some() {
346 fltk::app::repeat_timeout3(0.05, handle);
347 } else {
348 state_pg.borrow_mut().paging_timer_active = false;
349 }
350 });
351 } else if !more {
352 st.paging = None;
353 }
354 }
355 sb.redraw();
356 true
357 }
358 Event::Drag => {
359 let offset = state.borrow().drag_offset;
360 if let Some(offset) = offset {
361 let ey = fltk::app::event_y();
362 let y = sb.y();
363 let h = sb.h();
364 let (min, max) = (sb.minimum(), sb.maximum());
365 if let Some((_, thumb_h)) =
366 thumb_geometry(y, h, min, max, sb.value(), sb.slider_size())
367 {
368 let v = value_for_thumb_top(ey - offset, y, h, thumb_h, min, max);
369 sb.set_value(v);
370 sb.do_callback();
371 }
372 sb.redraw();
373 }
374 state.borrow_mut().state = ScrollbarState::Hovered;
375 true
376 }
377 Event::Released => {
378 // Clearing `paging` also tells the auto-repeat timer to stop.
379 let mut st = state.borrow_mut();
380 st.drag_offset = None;
381 st.paging = None;
382 st.state = ScrollbarState::Hovered;
383 st.last_wake_time = Instant::now();
384 drop(st);
385 sb.redraw();
386 true
387 }
388 _ => false,
389 }
390 }
391 });
392
393 // Run our handler *before* FLTK's built-in scrollbar logic, and let it
394 // consume Push/Drag/Release. This bypasses `Fl_Scrollbar`'s arrow-button
395 // track reservation entirely, so the thumb spans the full height.
396 scrollbar.super_handle_first(false);
397
398 // Set up periodic timer to check for auto-sleep (every 500ms)
399 {
400 let state_timer = state.clone();
401 let mut sb_timer = scrollbar.clone();
402 fltk::app::add_timeout3(0.1, move |handle| {
403 // Check if we need to transition to asleep
404 let needs_redraw = {
405 let mut st = state_timer.borrow_mut();
406 if st.state == ScrollbarState::Awake {
407 let elapsed = Instant::now().duration_since(st.last_wake_time);
408 if elapsed > Duration::from_secs(1) {
409 st.state = ScrollbarState::Asleep;
410 true
411 } else {
412 false
413 }
414 } else {
415 false
416 }
417 };
418
419 if needs_redraw {
420 sb_timer.redraw();
421 }
422
423 fltk::app::repeat_timeout3(0.1, handle);
424 });
425 }
426
427 Self { scrollbar, state }
428 }
429
430 /// Wake the scrollbar (transition to awake state)
431 pub fn wake(&mut self) {
432 let mut st = self.state.borrow_mut();
433 let old_state = st.state;
434 if st.state != ScrollbarState::Hovered {
435 st.state = ScrollbarState::Awake;
436 }
437 st.last_wake_time = Instant::now();
438 if old_state != st.state {
439 self.scrollbar.redraw();
440 }
441 }
442
443 /// Set the scrollbar type (vertical or horizontal)
444 pub fn set_type(&mut self, typ: fltk::valuator::ScrollbarType) {
445 // self.wake();
446 self.scrollbar.set_type(typ);
447 }
448
449 /// Set the bounds of the scrollbar
450 pub fn set_bounds(&mut self, min: f64, max: f64) {
451 // self.wake();
452 self.scrollbar.set_bounds(min, max);
453 }
454
455 /// Set the slider size (as a fraction)
456 pub fn set_slider_size(&mut self, size: f32) {
457 // self.wake();
458 self.scrollbar.set_slider_size(size);
459 }
460
461 /// Smallest slider-size fraction that still yields a thumb at least
462 /// `MIN_THUMB_HEIGHT` pixels tall, given the current scrollbar geometry.
463 /// Callers should clamp their computed `slider_size` to at least this.
464 pub fn min_slider_size(&self) -> f32 {
465 let h = self.scrollbar.h();
466 if h > 0 {
467 (MIN_THUMB_HEIGHT as f32 / h as f32).min(1.0)
468 } else {
469 1.0
470 }
471 }
472
473 /// Set the step sizes
474 pub fn set_step(&mut self, a: f64, b: i32) {
475 // self.wake();
476 self.scrollbar.set_step(a, b);
477 }
478
479 /// Set the value
480 pub fn set_value(&mut self, val: f64) {
481 // self.wake();
482 self.scrollbar.set_value(val);
483 }
484
485 /// Get the value
486 pub fn value(&self) -> f64 {
487 self.scrollbar.value()
488 }
489
490 /// Get the minimum value
491 pub fn minimum(&self) -> f64 {
492 self.scrollbar.minimum()
493 }
494
495 /// Get the maximum value
496 pub fn maximum(&self) -> f64 {
497 self.scrollbar.maximum()
498 }
499
500 /// Get the slider size
501 pub fn slider_size(&self) -> f32 {
502 self.scrollbar.slider_size()
503 }
504
505 /// Set a callback for when the scrollbar value changes
506 pub fn set_callback<F: FnMut(&mut Scrollbar) + 'static>(&mut self, cb: F) {
507 self.scrollbar.set_callback(cb);
508 }
509
510 /// Resize the scrollbar
511 pub fn resize(&mut self, x: i32, y: i32, w: i32, h: i32) {
512 self.scrollbar.resize(x, y, w, h);
513 }
514
515 /// Show the scrollbar
516 pub fn show(&mut self) {
517 self.scrollbar.show();
518 }
519
520 /// Redraw the scrollbar
521 pub fn redraw(&mut self) {
522 self.scrollbar.redraw();
523 }
524
525 /// Get the underlying scrollbar widget (for adding to parent)
526 pub fn as_base_widget(&self) -> fltk::widget::Widget {
527 self.scrollbar.as_base_widget()
528 }
529}