🔒 Repository is read-only – file editing is disabled.
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);
}
}