Skip to main content

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}