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

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

263 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
//! `Pager` — karuzela stron (przewijanie/„swipe” w poziomie z kropkami-wskaźnikami).
//!
//! Widget immediate: trzyma liczbę stron i bieżącą pozycję (ułamkową, w jednostkach stron).
//! Zawartość stron rysuje aplikacja, wyliczając prostokąty przez [`Pager::page_rect`];
//! sam widget rysuje tylko kropki-wskaźniki ([`Pager::paint`]), obsługuje ich klik
//! ([`Pager::indicator_at`]) oraz przeciąganie ([`Pager::begin_drag`] →
//! [`Pager::update_drag`] → [`Pager::end_drag`], które „przyciąga” do najbliższej strony).

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

use crate::ctx::PaintCtx;

/// Karuzela stron.
#[derive(Debug, Clone, Default)]
pub struct Pager {
    count: usize,
    /// Bieżąca pozycja: 0..count-1 (ułamkowa podczas przeciągania).
    position: f64,
    /// Pozycja wskaźnika (x) na początku przeciągania.
    drag_from: Option<f64>,
    /// Pozycja karuzeli na początku przeciągania.
    drag_pos: f64,
    /// Animowane „przyciąganie” do strony (po puszczeniu / skoku z kropek).
    snap: Animation,
}

impl Pager {
    pub fn new(count: usize) -> Self {
        Self {
            count,
            ..Self::default()
        }
    }

    pub fn count(&self) -> usize {
        self.count
    }

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

    /// Ustawia pozycję (przycinaną do zakresu stron).
    pub fn set_position(&mut self, position: f64) {
        self.position = position.clamp(0.0, self.max_position());
    }

    /// Aktywna strona (najbliższa pozycji).
    pub fn active(&self) -> usize {
        self.position.round().clamp(0.0, self.max_position()) as usize
    }

    /// Ustawia aktywną stronę.
    pub fn set_active(&mut self, index: usize) {
        let max = self.max_position();
        self.position = (index as f64).clamp(0.0, max);
    }

    pub fn next(&mut self) {
        self.set_active(self.active() + 1);
    }

    pub fn prev(&mut self) {
        self.set_active(self.active().saturating_sub(1));
    }

    /// Prostokąt strony `i` przy bieżącej pozycji (strony sąsiadujące są poza obszarem).
    pub fn page_rect(&self, rect: Rect, i: usize) -> Rect {
        let x = rect.x() + (i as f64 - self.position) * rect.width();
        Rect::from_xywh(x, rect.y(), rect.width(), rect.height())
    }

    /// Prostokąty kropek-wskaźników (wyśrodkowane przy dolnej krawędzi).
    pub fn indicator_rects(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
        let m = theme.metrics.pager;
        let n = self.count;
        if n == 0 {
            return Vec::new();
        }
        let total = n as f64 * m.dot + (n as f64 - 1.0) * m.gap;
        let mut x = rect.center().x - total / 2.0;
        let y = rect.bottom() - m.pad - m.dot;
        let mut out = Vec::with_capacity(n);
        for _ in 0..n {
            out.push(Rect::from_xywh(x, y, m.dot, m.dot));
            x += m.dot + m.gap;
        }
        out
    }

    /// Indeks kropki pod punktem `p` (z tolerancją trafienia).
    pub fn indicator_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
        self.indicator_rects(rect, theme).into_iter().position(|r| {
            r.inflate(Insets::splat(theme.metrics.pager.gap / 2.0))
                .contains(p)
        })
    }

    /// Klik w kropkę: ustawia stronę. Zwraca `true`, jeśli trafiono.
    pub fn click_indicator(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        match self.indicator_at(rect, theme, p) {
            Some(i) => {
                self.set_active(i);
                true
            }
            None => false,
        }
    }

    /// Rozpoczyna przeciąganie od pozycji wskaźnika `x`.
    pub fn begin_drag(&mut self, x: f64) {
        self.drag_from = Some(x);
        self.drag_pos = self.position;
    }

    /// Aktualizuje przeciągnięcie w `rect`.
    pub fn update_drag(&mut self, x: f64, rect: Rect) {
        if let Some(from) = self.drag_from {
            if rect.width() > 0.0 {
                self.set_position(self.drag_pos - (x - from) / rect.width());
            }
        }
    }

    /// Kończy przeciąganie, „przyciągając” do najbliższej strony. Zwraca aktywny indeks.
    pub fn end_drag(&mut self) -> usize {
        self.drag_from = None;
        let active = self.active();
        self.set_active(active);
        active
    }

    /// Kończy przeciąganie z **animowanym** przyciąganiem do najbliższej strony.
    pub fn end_drag_animated(&mut self) -> usize {
        self.drag_from = None;
        let active = self.active();
        self.snap.jump_to(self.position);
        self.snap.animate_to(active as f64);
        active
    }

    /// Skok do strony z animacją (np. klik w kropkę).
    pub fn set_active_animated(&mut self, index: usize) {
        let target = (index as f64).clamp(0.0, self.max_position());
        self.snap.jump_to(self.position);
        self.snap.animate_to(target);
    }

    pub fn is_animating(&self) -> bool {
        self.snap.is_animating()
    }

    /// Ustawia krzywą wygładzania przyciągania.
    pub fn set_snap_easing(&mut self, easing: Easing) {
        self.snap.set_easing(easing);
    }

    /// Krok animacji przyciągania; zwraca `true`, gdy trwa dalej.
    pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
        if !self.snap.is_animating() {
            return false;
        }
        let animating = self.snap.advance(dt, duration);
        self.position = self.snap.value().clamp(0.0, self.max_position());
        animating
    }

    /// Rysuje kropki-wskaźniki.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let active = self.active();
        for (i, r) in self.indicator_rects(rect, &theme).iter().enumerate() {
            let color = if i == active {
                ctx.theme.colors.accent
            } else {
                ctx.theme.colors.dim_text.scale_alpha(0.6)
            };
            ctx.scene.fill_rounded_rect(*r, r.width() / 2.0, color);
        }
    }

    fn max_position(&self) -> f64 {
        self.count.saturating_sub(1) as f64
    }
}

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

    fn rect() -> Rect {
        Rect::from_xywh(0.0, 0.0, 300.0, 200.0)
    }

    #[test]
    fn pages_are_offset_by_position() {
        let p = Pager::new(3);
        assert_eq!(p.page_rect(rect(), 0), rect());
        let second = p.page_rect(rect(), 1);
        assert!((second.x() - 300.0).abs() < 1e-9);
        let mut p = Pager::new(3);
        p.set_active(1);
        let first = p.page_rect(rect(), 0);
        assert!((first.x() + 300.0).abs() < 1e-9);
    }

    #[test]
    fn position_and_active_clamp() {
        let mut p = Pager::new(3);
        p.set_position(9.0);
        assert_eq!(p.active(), 2);
        p.set_active(0);
        assert_eq!(p.active(), 0);
        p.prev();
        assert_eq!(p.active(), 0); // nie schodzi poniżej zera
        p.next();
        assert_eq!(p.active(), 1);
    }

    #[test]
    fn indicator_hit_selects_page() {
        let theme = Theme::light();
        let mut p = Pager::new(3);
        let dots = p.indicator_rects(rect(), &theme);
        assert_eq!(dots.len(), 3);
        assert!(p.click_indicator(rect(), &theme, dots[2].center()));
        assert_eq!(p.active(), 2);
    }

    #[test]
    fn drag_snaps_to_nearest_page() {
        let mut p = Pager::new(3);
        p.begin_drag(0.0);
        // Przeciągnięcie w lewo o 1.4 szerokości → pozycja 1.4 → snap do 1.
        p.update_drag(-420.0, rect());
        assert!((p.position() - 1.4).abs() < 1e-9);
        assert_eq!(p.end_drag(), 1);
        assert!((p.position() - 1.0).abs() < 1e-9);
    }

    #[test]
    fn animated_snap_settles_on_target() {
        let mut p = Pager::new(3);
        p.begin_drag(0.0);
        p.update_drag(-420.0, rect());
        assert_eq!(p.end_drag_animated(), 1);
        assert!(p.is_animating());
        assert!(p.advance(0.5, 1.0));
        assert!(!p.advance(0.5, 1.0));
        assert!((p.position() - 1.0).abs() < 1e-9);
    }

    #[test]
    fn dot_jump_animates() {
        let mut p = Pager::new(4);
        p.set_active_animated(3);
        assert!(p.is_animating());
        while p.is_animating() {
            p.advance(0.1, 1.0);
        }
        assert_eq!(p.active(), 3);
    }
}