🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/scrollbar.rs main

380 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
//! `Scrollbar` + `Adjustment` — pasek przewijania i model zakresu (GTK4, w duchu libLuny).
//!
//! [`Adjustment`] to mały model zakresu (`lower`..`upper` z oknem `page`), a [`Scrollbar`]
//! go prezentuje: liczy geometrię „kciuka”, obsługuje przeciąganie i rolkę. Działa w obu
//! orientacjach, więc uzupełnia pionowy [`ScrollView`](crate::ScrollView) o wariant poziomy.

use libluna_core::{Insets, Point, Rect, Theme};

use crate::ctx::PaintCtx;
use crate::display::Orientation;

/// Model zakresu przewijania (`lower`..`upper`, widoczne okno `page`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Adjustment {
    value: f64,
    lower: f64,
    upper: f64,
    page: f64,
}

impl Adjustment {
    /// Zakres `lower..upper` z oknem `page`, ustawiony na `lower`.
    pub fn new(lower: f64, upper: f64, page: f64) -> Self {
        Self {
            value: lower,
            lower,
            upper,
            page: page.max(0.0),
        }
    }

    pub fn value(&self) -> f64 {
        self.value
    }

    pub fn lower(&self) -> f64 {
        self.lower
    }

    pub fn upper(&self) -> f64 {
        self.upper
    }

    pub fn page_size(&self) -> f64 {
        self.page
    }

    /// Maksymalna wartość (`upper - page`, nie mniej niż `lower`).
    pub fn max_value(&self) -> f64 {
        (self.upper - self.page).max(self.lower)
    }

    /// Czy jest co przewijać.
    pub fn is_scrollable(&self) -> bool {
        self.max_value() > self.lower + 1e-9
    }

    /// Ustawia wartość (przyciętą do zakresu).
    pub fn set_value(&mut self, value: f64) {
        self.value = value.clamp(self.lower, self.max_value());
    }

    /// Zmienia zakres (wartość przycinana do nowego zakresu).
    pub fn set_bounds(&mut self, lower: f64, upper: f64, page: f64) {
        self.lower = lower;
        self.upper = upper;
        self.page = page.max(0.0);
        self.set_value(self.value);
    }

    /// Postęp przewinięcia w zakresie 0..1.
    pub fn fraction(&self) -> f64 {
        let span = self.max_value() - self.lower;
        if span > 0.0 {
            (self.value - self.lower) / span
        } else {
            0.0
        }
    }

    /// Ustawia przewinięcie z postępu 0..1.
    pub fn set_fraction(&mut self, fraction: f64) {
        let span = self.max_value() - self.lower;
        self.set_value(self.lower + span * fraction.clamp(0.0, 1.0));
    }

    /// Przewija o `delta` jednostek (przycinane).
    pub fn scroll_by(&mut self, delta: f64) {
        self.set_value(self.value + delta);
    }
}

/// Pasek przewijania (pionowy lub poziomy).
#[derive(Debug, Clone, Copy, Default)]
pub struct Scrollbar {
    orientation: Orientation,
    adjustment: Adjustment,
    hovered: bool,
    dragging: bool,
    drag_grab: Option<f64>,
}

impl Scrollbar {
    pub fn new(orientation: Orientation) -> Self {
        Self {
            orientation,
            ..Self::default()
        }
    }

    pub fn orientation(&self) -> Orientation {
        self.orientation
    }

    pub fn adjustment(&self) -> &Adjustment {
        &self.adjustment
    }

    pub fn adjustment_mut(&mut self) -> &mut Adjustment {
        &mut self.adjustment
    }

    pub fn set_adjustment(&mut self, adjustment: Adjustment) {
        self.adjustment = adjustment;
    }

    pub fn set_hovered(&mut self, hovered: bool) {
        self.hovered = hovered;
    }

    pub fn is_dragging(&self) -> bool {
        self.dragging
    }

    fn track_length(&self, viewport: Rect) -> f64 {
        match self.orientation {
            Orientation::Vertical => viewport.height(),
            Orientation::Horizontal => viewport.width(),
        }
    }

    /// Tor paska wewnątrz `viewport`.
    pub fn track_rect(&self, viewport: Rect, theme: &Theme) -> Rect {
        let m = theme.metrics.scrollview;
        match self.orientation {
            Orientation::Vertical => Rect::from_xywh(
                viewport.right() - m.margin - m.thickness,
                viewport.y(),
                m.thickness,
                viewport.height(),
            ),
            Orientation::Horizontal => Rect::from_xywh(
                viewport.x(),
                viewport.bottom() - m.margin - m.thickness,
                viewport.width(),
                m.thickness,
            ),
        }
    }

    /// Prostokąt „kciuka” (albo `None`, gdy nie ma czego przewijać).
    pub fn thumb_rect(&self, viewport: Rect, theme: &Theme) -> Option<Rect> {
        if !self.adjustment.is_scrollable() {
            return None;
        }
        let m = theme.metrics.scrollview;
        let track = self.track_length(viewport);
        let content = (self.adjustment.upper() - self.adjustment.lower()).max(1e-9);
        let frac = (self.adjustment.page_size() / content).clamp(0.0, 1.0);
        let thumb_len = (track * frac).clamp(m.min_thumb.min(track), track);
        let t = self.adjustment.fraction();
        match self.orientation {
            Orientation::Vertical => {
                let y = viewport.y() + t * (track - thumb_len);
                Some(Rect::from_xywh(
                    viewport.right() - m.margin - m.thickness,
                    y,
                    m.thickness,
                    thumb_len,
                ))
            }
            Orientation::Horizontal => {
                let x = viewport.x() + t * (track - thumb_len);
                Some(Rect::from_xywh(
                    x,
                    viewport.bottom() - m.margin - m.thickness,
                    thumb_len,
                    m.thickness,
                ))
            }
        }
    }

    fn hit_pad(&self, theme: &Theme) -> Insets {
        let t = theme.metrics.scrollview.thickness;
        match self.orientation {
            Orientation::Vertical => Insets::new(0.0, t, 0.0, 4.0),
            Orientation::Horizontal => Insets::new(0.0, 4.0, 0.0, t),
        }
    }

    /// Czy punkt trafia w „kciuk”.
    pub fn thumb_at(&self, viewport: Rect, theme: &Theme, p: Point) -> bool {
        self.thumb_rect(viewport, theme)
            .is_some_and(|t| t.inflate(self.hit_pad(theme)).contains(p))
    }

    /// Przewija o `pixels` pikseli toru; zwraca `true`, gdy da się przewijać.
    pub fn on_scroll(&mut self, pixels: f64, viewport: Rect) -> bool {
        if !self.adjustment.is_scrollable() {
            return false;
        }
        let track = self.track_length(viewport);
        if track <= 0.0 {
            return false;
        }
        let frac = pixels / track;
        self.adjustment
            .set_fraction(self.adjustment.fraction() + frac);
        true
    }

    /// Rozpoczyna przeciąganie „kciuka” (albo skok w tor).
    pub fn begin_drag(&mut self, p: Point, viewport: Rect, theme: &Theme) -> bool {
        if !self.adjustment.is_scrollable() {
            return false;
        }
        if let Some(thumb) = self.thumb_rect(viewport, theme) {
            let (along, thumb_start, thumb_len) = match self.orientation {
                Orientation::Vertical => (p.y, thumb.y(), thumb.height()),
                Orientation::Horizontal => (p.x, thumb.x(), thumb.width()),
            };
            if thumb.inflate(self.hit_pad(theme)).contains(p) {
                self.drag_grab = Some(along - thumb_start);
                self.dragging = true;
                return true;
            }
            // Klik w tor poza kciukiem: wyśrodkuj kciuk i zacznij przeciąganie.
            if self.track_rect(viewport, theme).contains(p) {
                let t = (along - thumb_start - thumb_len / 2.0).max(0.0);
                let track = self.track_length(viewport);
                let denom = (track - thumb_len).max(1e-9);
                self.adjustment.set_fraction(t / denom);
                self.drag_grab = Some(thumb_len / 2.0);
                self.dragging = true;
                return true;
            }
        }
        false
    }

    /// Aktualizuje przeciąganie „kciuka”.
    pub fn drag_to(&mut self, p: Point, viewport: Rect, theme: &Theme) {
        if !self.dragging {
            return;
        }
        let Some(thumb) = self.thumb_rect(viewport, theme) else {
            return;
        };
        let track = self.track_length(viewport);
        let thumb_len = match self.orientation {
            Orientation::Vertical => thumb.height(),
            Orientation::Horizontal => thumb.width(),
        };
        let (along, start) = match self.orientation {
            Orientation::Vertical => (p.y, viewport.y()),
            Orientation::Horizontal => (p.x, viewport.x()),
        };
        let grab = self.drag_grab.unwrap_or(0.0);
        let denom = (track - thumb_len).max(1e-9);
        self.adjustment
            .set_fraction(((along - grab - start) / denom).clamp(0.0, 1.0));
    }

    /// Kończy przeciąganie.
    pub fn end_drag(&mut self) {
        self.dragging = false;
        self.drag_grab = None;
    }

    /// Rysuje „kciuk” (jeśli jest co przewijać).
    pub fn paint(&self, ctx: &mut PaintCtx, viewport: Rect) {
        let theme = *ctx.theme;
        if let Some(t) = self.thumb_rect(viewport, &theme) {
            let alpha = if self.dragging {
                0.7
            } else if self.hovered {
                0.55
            } else {
                0.45
            };
            let radius = match self.orientation {
                Orientation::Vertical => t.width() / 2.0,
                Orientation::Horizontal => t.height() / 2.0,
            };
            ctx.scene
                .fill_rounded_rect(t, radius, theme.colors.dim_text.scale_alpha(alpha));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn viewport() -> Rect {
        Rect::from_xywh(0.0, 0.0, 200.0, 100.0)
    }

    #[test]
    fn adjustment_fraction_and_clamping() {
        let mut a = Adjustment::new(0.0, 100.0, 25.0);
        assert_eq!(a.max_value(), 75.0);
        a.set_value(999.0);
        assert_eq!(a.value(), 75.0);
        assert_eq!(a.fraction(), 1.0);
        a.set_fraction(0.5);
        assert_eq!(a.value(), 37.5);
        a.set_value(-10.0);
        assert_eq!(a.value(), 0.0);
    }

    #[test]
    fn not_scrollable_when_page_covers_range() {
        let a = Adjustment::new(0.0, 100.0, 120.0);
        assert!(!a.is_scrollable());
        let theme = Theme::light();
        let mut s = Scrollbar::new(Orientation::Vertical);
        s.set_adjustment(a);
        assert!(s.thumb_rect(viewport(), &theme).is_none());
        assert!(!s.on_scroll(50.0, viewport()));
    }

    #[test]
    fn thumb_length_tracks_page_size() {
        let theme = Theme::light();
        let mut s = Scrollbar::new(Orientation::Vertical);
        s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
        let t = s.thumb_rect(viewport(), &theme).unwrap();
        assert!((t.height() - 50.0).abs() < 1e-9);
        // Na końcu kciuk dojeżdża do dołu toru.
        s.adjustment_mut().set_fraction(1.0);
        let t2 = s.thumb_rect(viewport(), &theme).unwrap();
        assert!((t2.bottom() - viewport().bottom()).abs() < 1e-9);
    }

    #[test]
    fn horizontal_thumb_moves_along_x() {
        let theme = Theme::light();
        let mut s = Scrollbar::new(Orientation::Horizontal);
        s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
        let t = s.thumb_rect(viewport(), &theme).unwrap();
        assert!(t.x() >= viewport().x());
        s.adjustment_mut().set_fraction(1.0);
        let t2 = s.thumb_rect(viewport(), &theme).unwrap();
        assert!((t2.right() - viewport().right()).abs() < 1e-9);
    }

    #[test]
    fn drag_moves_to_end() {
        let theme = Theme::light();
        let mut s = Scrollbar::new(Orientation::Vertical);
        s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
        let t = s.thumb_rect(viewport(), &theme).unwrap();
        assert!(s.begin_drag(t.center(), viewport(), &theme));
        assert!(s.is_dragging());
        s.drag_to(Point::new(t.center().x, 500.0), viewport(), &theme);
        assert_eq!(s.adjustment().fraction(), 1.0);
        s.end_drag();
        assert!(!s.is_dragging());
    }

    #[test]
    fn scroll_pixels_change_fraction() {
        let mut s = Scrollbar::new(Orientation::Vertical);
        s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
        assert!(s.on_scroll(25.0, viewport())); // 25/100 toru → +0.25
        assert!((s.adjustment().fraction() - 0.25).abs() < 1e-9);
    }
}