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

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

184 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
//! `ScrollView` — przewijanie zawartości (kołysko/„kciuk” + obsługa rolki i przeciągania).
//!
//! Widget immediate: trzyma przesunięcie i wysokość zawartości. Aplikacja rysuje swoją treść
//! z uwzględnieniem [`ScrollView::offset`], przycina do obszaru, a widget rysuje pasek
//! ([`ScrollView::paint_scrollbar`]) i obsługuje rolkę ([`ScrollView::on_scroll`]) oraz
//! przeciąganie „kciuka” ([`ScrollView::begin_drag`] / [`ScrollView::drag_to`] / [`ScrollView::end_drag`]).

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

use crate::ctx::PaintCtx;

/// Przewijanie pionowe.
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrollView {
    offset: f64,
    content_height: f64,
    /// Przesunięcie wskaźnika (y) względem początku „kciuka” na starcie przeciągania.
    drag_grab: Option<f64>,
}

impl ScrollView {
    pub fn new() -> Self {
        Self::default()
    }

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

    pub fn set_content_height(&mut self, height: f64) {
        self.content_height = height.max(0.0);
    }

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

    /// Maksymalne przesunięcie (0, gdy wszystko się mieści).
    pub fn max_offset(&self, viewport: Rect) -> f64 {
        (self.content_height - viewport.height()).max(0.0)
    }

    pub fn is_scrollable(&self, viewport: Rect) -> bool {
        self.max_offset(viewport) > 0.5
    }

    /// Ustawia przesunięcie (przycięte do zakresu).
    pub fn set_offset(&mut self, offset: f64, viewport: Rect) {
        self.offset = offset.clamp(0.0, self.max_offset(viewport));
    }

    /// Przewija o `dy`; zwraca `true`, jeśli zawartość da się przewijać.
    pub fn on_scroll(&mut self, dy: f64, viewport: Rect) -> bool {
        if !self.is_scrollable(viewport) {
            return false;
        }
        self.set_offset(self.offset + dy, viewport);
        true
    }

    /// Prostokąt „kciuka” paska przewijania (albo `None`, gdy nie ma czego przewijać).
    pub fn thumb_rect(&self, viewport: Rect, theme: &Theme) -> Option<Rect> {
        if !self.is_scrollable(viewport) {
            return None;
        }
        let m = theme.metrics.scrollview;
        let track_h = viewport.height();
        let frac = (viewport.height() / self.content_height).clamp(0.0, 1.0);
        let thumb_h = (track_h * frac).clamp(m.min_thumb, track_h);
        let max = self.max_offset(viewport);
        let t = if max > 0.0 { self.offset / max } else { 0.0 };
        let y = viewport.y() + t * (track_h - thumb_h);
        Some(Rect::from_xywh(
            viewport.right() - m.margin - m.thickness,
            y,
            m.thickness,
            thumb_h,
        ))
    }

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

    /// Rozpoczyna przeciąganie „kciuka” (jeśli punkt w niego trafia).
    pub fn begin_drag(&mut self, p: Point, viewport: Rect, theme: &Theme) -> bool {
        match self.thumb_rect(viewport, theme) {
            Some(t) if t.contains(p) => {
                self.drag_grab = Some(p.y - t.y());
                true
            }
            _ => false,
        }
    }

    /// Aktualizuje przeciąganie „kciuka”.
    pub fn drag_to(&mut self, p: Point, viewport: Rect, theme: &Theme) {
        let Some(grab) = self.drag_grab else { return };
        let Some(thumb) = self.thumb_rect(viewport, theme) else {
            return;
        };
        let range = viewport.height() - thumb.height();
        if range > 0.0 {
            let t = ((p.y - grab - viewport.y()) / range).clamp(0.0, 1.0);
            self.offset = t * self.max_offset(viewport);
        }
    }

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

    /// Rysuje pasek przewijania (jeśli jest co przewijać).
    pub fn paint_scrollbar(&self, ctx: &mut PaintCtx, viewport: Rect) {
        let theme = *ctx.theme;
        if let Some(t) = self.thumb_rect(viewport, &theme) {
            ctx.scene.fill_rounded_rect(
                t,
                t.width() / 2.0,
                theme.colors.dim_text.scale_alpha(0.45),
            );
        }
    }
}

fn luna_insets(theme: &Theme) -> libluna_core::Insets {
    libluna_core::Insets::new(0.0, theme.metrics.scrollview.thickness, 0.0, 4.0)
}

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

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

    #[test]
    fn not_scrollable_when_content_fits() {
        let mut s = ScrollView::new();
        s.set_content_height(80.0);
        assert!(!s.is_scrollable(viewport()));
        assert!(!s.on_scroll(50.0, viewport()));
        assert!(s.thumb_rect(viewport(), &Theme::light()).is_none());
    }

    #[test]
    fn offset_is_clamped() {
        let mut s = ScrollView::new();
        s.set_content_height(300.0);
        s.set_offset(999.0, viewport());
        assert_eq!(s.offset(), 200.0);
        s.set_offset(-5.0, viewport());
        assert_eq!(s.offset(), 0.0);
    }

    #[test]
    fn thumb_shrinks_with_content() {
        let theme = Theme::light();
        let mut s = ScrollView::new();
        s.set_content_height(200.0); // viewport 100 → kciuk ~ połowa toru
        let t = s.thumb_rect(viewport(), &theme).unwrap();
        assert!((t.height() - 50.0).abs() < 1e-9);
        // Na dole kciuk dojeżdża do końca toru.
        s.set_offset(100.0, viewport());
        let t2 = s.thumb_rect(viewport(), &theme).unwrap();
        assert!((t2.bottom() - viewport().bottom()).abs() < 1e-9);
    }

    #[test]
    fn drag_moves_thumb_to_bottom() {
        let theme = Theme::light();
        let mut s = ScrollView::new();
        s.set_content_height(200.0);
        let t = s.thumb_rect(viewport(), &theme).unwrap();
        assert!(s.begin_drag(t.center(), viewport(), &theme));
        s.drag_to(Point::new(t.center().x, 500.0), viewport(), &theme);
        assert_eq!(s.offset(), s.max_offset(viewport()));
        s.end_drag();
    }
}