use alloc::vec::Vec;
use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Size};
use denise_render::Canvas;
use crate::widget::{Animation, Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::image::{Fit, Image};
use crate::widgets::style::{focus_ring, interactive_pair};
const SLIDE_MS: u64 = 250;
const FRAME_MS: u64 = 50;
const COMMIT_DIVISOR: i32 = 4;
const WHOLE: i32 = 1024;
const DOT: i32 = 4;
const DOT_GAP: i32 = 14;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Motion {
Still,
Dragging { fraction: i32 },
Sliding { fraction: i32, from_ms: u64 },
}
#[derive(Clone, Debug)]
pub struct Carousel<M> {
pages: Vec<Image>,
current: usize,
motion: Motion,
grip: Option<(Point, i32)>,
advance_ms: Option<u64>,
held_since: u64,
message: Option<fn(usize) -> M>,
role: Role,
}
impl<M> Carousel<M> {
pub fn new(message: fn(usize) -> M) -> Self {
Self {
pages: Vec::new(),
current: 0,
motion: Motion::Still,
grip: None,
advance_ms: None,
held_since: 0,
message: Some(message),
role: Role::Primary,
}
}
pub fn inert() -> Self {
Self {
pages: Vec::new(),
current: 0,
motion: Motion::Still,
grip: None,
advance_ms: None,
held_since: 0,
message: None,
role: Role::Primary,
}
}
pub fn with_picture(mut self, pixels: Vec<u32>, size: Size) -> Self {
self.pages
.push(Image::new(pixels, size).with_fit(Fit::Cover));
self
}
pub fn with_picture_fit(mut self, pixels: Vec<u32>, size: Size, fit: Fit) -> Self {
self.pages.push(Image::new(pixels, size).with_fit(fit));
self
}
pub fn auto_advance(mut self, interval_ms: u64) -> Self {
self.advance_ms = Some(interval_ms.max(SLIDE_MS * 2));
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
#[inline]
pub const fn current(&self) -> usize {
self.current
}
#[inline]
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn set_current(&mut self, index: usize) {
if index < self.pages.len() {
self.current = index;
self.motion = Motion::Still;
}
}
pub fn push_picture(&mut self, pixels: Vec<u32>, size: Size) -> usize {
self.pages
.push(Image::new(pixels, size).with_fit(Fit::Cover));
self.pages.len() - 1
}
fn neighbour(&self, steps: i32) -> usize {
let count = self.pages.len().max(1) as i32;
(self.current as i32 + steps).rem_euclid(count) as usize
}
fn fraction_at(&self, now_ms: u64) -> i32 {
match self.motion {
Motion::Still => 0,
Motion::Dragging { fraction } => fraction,
Motion::Sliding { fraction, from_ms } => {
slide_fraction(fraction, now_ms.saturating_sub(from_ms))
}
}
}
fn arrive(&mut self, target: usize, fraction: i32, ctx: &mut EventCtx<'_, M>) {
self.current = target;
self.motion = Motion::Sliding {
fraction,
from_ms: ctx.now_ms,
};
if let Some(message) = self.message {
ctx.emit(message(target));
}
ctx.request_animation();
}
}
fn slide_fraction(fraction: i32, elapsed: u64) -> i32 {
if elapsed >= SLIDE_MS {
return 0;
}
let remaining = (SLIDE_MS - elapsed) as i64;
(i64::from(fraction) * remaining / SLIDE_MS as i64) as i32
}
impl<M: 'static> Widget<M> for Carousel<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let (backdrop, _) = interactive_pair(ctx.theme, Role::Base200, ctx.state);
canvas.fill_rect(bounds, backdrop);
if self.pages.is_empty() {
return;
}
let fraction = self.fraction_at(ctx.now_ms);
let offset = (i64::from(fraction) * i64::from(bounds.width) / i64::from(WHOLE)) as i32;
{
let mut c = canvas.with_clip(bounds);
let page_at = |c: &mut Canvas<'_>, index: usize, dx: i32| {
if let Some(page) = self.pages.get(index) {
let shifted = Rect::new(bounds.x + dx, bounds.y, bounds.width, bounds.height);
page.paint_at(shifted, 0, c);
}
};
page_at(&mut c, self.current, offset);
if offset > 0 {
page_at(&mut c, self.neighbour(-1), offset - bounds.width);
} else if offset < 0 {
page_at(&mut c, self.neighbour(1), offset + bounds.width);
}
}
if self.pages.len() > 1 {
let count = self.pages.len() as i32;
let span = (count - 1) * DOT_GAP;
let mut x = bounds.x + (bounds.width - span) / 2;
let y = bounds.bottom() - DOT * 3;
let (accent, _) = interactive_pair(ctx.theme, self.role, ctx.state);
let rim = ctx.theme.color(Role::Base100);
for index in 0..count as usize {
let centre = Point::new(x, y);
canvas.fill_circle(centre, DOT + 1, rim);
if index == self.current {
canvas.fill_circle(centre, DOT, accent);
} else {
canvas.stroke_circle(centre, DOT, 1, ctx.theme.color(Role::Base300));
}
x += DOT_GAP;
}
}
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
bounds,
ctx.theme.radius(denise::Radius::Field),
canvas,
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
if self.pages.len() < 2 {
return Handled::No;
}
let width = ctx.bounds.width.max(1);
match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Down,
position,
..
})
| Event::Input(InputEvent::TouchDown { position, .. }) => {
if !ctx.bounds.contains(*position) {
return Handled::No;
}
self.grip = Some((*position, width));
self.motion = Motion::Dragging {
fraction: self.fraction_at(ctx.now_ms),
};
self.held_since = ctx.now_ms;
Handled::Yes
}
Event::Input(InputEvent::PointerMoved { position })
| Event::Input(InputEvent::TouchMoved { position, .. }) => {
let Some((grip, width)) = self.grip else {
return Handled::No;
};
let fraction = ((i64::from(position.x - grip.x) * i64::from(WHOLE))
/ i64::from(width.max(1))) as i32;
let fraction = fraction.clamp(-WHOLE, WHOLE);
if self.motion == (Motion::Dragging { fraction }) {
return Handled::No;
}
self.motion = Motion::Dragging { fraction };
Handled::Yes
}
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
..
})
| Event::Input(InputEvent::TouchUp { .. }) => {
if self.grip.take().is_none() {
return Handled::No;
}
let fraction = match self.motion {
Motion::Dragging { fraction } => fraction,
_ => 0,
};
let commit = WHOLE / COMMIT_DIVISOR;
if fraction <= -commit {
self.arrive(self.neighbour(1), fraction + WHOLE, ctx);
} else if fraction >= commit {
self.arrive(self.neighbour(-1), fraction - WHOLE, ctx);
} else if fraction != 0 {
self.motion = Motion::Sliding {
fraction,
from_ms: ctx.now_ms,
};
ctx.request_animation();
} else {
self.motion = Motion::Still;
}
Handled::Yes
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => match code {
KeyCode::ArrowRight | KeyCode::ArrowDown => {
self.arrive(self.neighbour(1), WHOLE, ctx);
Handled::Yes
}
KeyCode::ArrowLeft | KeyCode::ArrowUp => {
self.arrive(self.neighbour(-1), -WHOLE, ctx);
Handled::Yes
}
KeyCode::Home if self.current != 0 => {
self.arrive(0, -WHOLE, ctx);
Handled::Yes
}
KeyCode::End if self.current != self.pages.len() - 1 => {
self.arrive(self.pages.len() - 1, WHOLE, ctx);
Handled::Yes
}
KeyCode::Home | KeyCode::End => Handled::Yes,
_ => Handled::No,
},
_ => Handled::No,
}
}
fn animate(&mut self, now_ms: u64) -> Animation {
match self.motion {
Motion::Sliding { from_ms, .. } => {
if now_ms.saturating_sub(from_ms) >= SLIDE_MS {
self.motion = Motion::Still;
self.held_since = now_ms;
Animation {
repaint: true,
next_ms: self.advance_ms.map(|interval| now_ms + interval),
}
} else {
Animation {
repaint: true,
next_ms: Some(now_ms + FRAME_MS),
}
}
}
Motion::Dragging { .. } => Animation {
repaint: false,
next_ms: self.advance_ms.map(|interval| now_ms + interval),
},
Motion::Still => match self.advance_ms {
None => Animation::NONE,
Some(interval) if self.pages.len() < 2 => Animation {
repaint: false,
next_ms: Some(now_ms + interval),
},
Some(interval) => {
let due = self.held_since.saturating_add(interval);
if now_ms < due {
Animation {
repaint: false,
next_ms: Some(due),
}
} else {
self.current = self.neighbour(1);
self.motion = Motion::Sliding {
fraction: WHOLE,
from_ms: now_ms,
};
self.held_since = now_ms;
Animation {
repaint: true,
next_ms: Some(now_ms + FRAME_MS),
}
}
}
},
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
self.message.is_some() && self.pages.len() > 1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn picture(word: u32) -> (Vec<u32>, Size) {
(alloc::vec![word; 16], Size::new(4, 4))
}
fn carousel(pages: usize) -> Carousel<usize> {
let mut c = Carousel::new(|index| index);
for i in 0..pages {
let (px, size) = picture(0xFF00_0000 | i as u32);
c.pages.push(Image::new(px, size).with_fit(Fit::Cover));
}
c
}
#[test]
fn the_neighbour_wraps_in_both_directions() {
let mut c = carousel(3);
assert_eq!(c.neighbour(1), 1);
c.set_current(2);
assert_eq!(c.neighbour(1), 0, "forward past the end wraps");
c.set_current(0);
assert_eq!(c.neighbour(-1), 2, "backward past the start wraps");
}
#[test]
fn a_slide_runs_to_exactly_rest() {
assert_eq!(slide_fraction(WHOLE, 0), WHOLE);
assert_eq!(slide_fraction(WHOLE, SLIDE_MS), 0);
assert_eq!(slide_fraction(WHOLE, SLIDE_MS * 10), 0, "and stays there");
assert_eq!(slide_fraction(WHOLE, SLIDE_MS / 2), WHOLE / 2);
assert_eq!(slide_fraction(-WHOLE, SLIDE_MS / 2), -WHOLE / 2);
let mut previous = WHOLE;
for at in 0..=SLIDE_MS {
let now = slide_fraction(WHOLE, at);
assert!(now <= previous, "the slide went backwards at {at}");
previous = now;
}
}
#[test]
fn holding_asks_for_one_wake_at_the_deadline() {
let mut c = carousel(3).auto_advance(8_000);
c.held_since = 1_000;
let hold = Widget::<usize>::animate(&mut c, 2_000);
assert!(!hold.repaint, "a hold must not repaint");
assert_eq!(hold.next_ms, Some(9_000), "one wake, at the deadline");
let again = Widget::<usize>::animate(&mut c, 5_000);
assert_eq!(again.next_ms, Some(9_000));
assert_eq!(c.current(), 0, "and the page has not moved");
}
#[test]
fn the_advance_clock_slides_and_then_rests() {
let mut c = carousel(3).auto_advance(8_000);
c.held_since = 0;
let due = Widget::<usize>::animate(&mut c, 8_000);
assert!(due.repaint);
assert_eq!(due.next_ms, Some(8_000 + FRAME_MS), "sliding at frame rate");
assert_eq!(c.current(), 1);
let settled = Widget::<usize>::animate(&mut c, 8_000 + SLIDE_MS);
assert!(settled.repaint, "the landing frame paints");
assert_eq!(settled.next_ms, Some(8_000 + SLIDE_MS + 8_000));
assert_eq!(c.motion, Motion::Still);
let mid_hold = Widget::<usize>::animate(&mut c, 8_000 + SLIDE_MS + 1_000);
assert!(!mid_hold.repaint);
assert_eq!(mid_hold.next_ms, Some(8_000 + SLIDE_MS + 8_000));
assert_eq!(c.current(), 1, "an early ask must not advance the page");
}
#[test]
fn a_still_carousel_without_a_clock_costs_nothing() {
let mut c = carousel(3);
assert_eq!(Widget::<usize>::animate(&mut c, 5_000), Animation::NONE);
}
#[test]
fn one_page_neither_rotates_nor_takes_focus() {
let mut c = carousel(1).auto_advance(1_000);
c.held_since = 0;
let asked = Widget::<usize>::animate(&mut c, 10_000);
assert!(!asked.repaint);
assert_eq!(c.current(), 0, "nowhere to go");
assert!(!Widget::<usize>::focusable(&c));
assert!(Widget::<usize>::focusable(&carousel(2)));
assert!(
!Widget::<usize>::focusable(&Carousel::<usize>::inert()),
"a carousel nobody listens to is display"
);
}
#[test]
fn set_current_is_silent_and_clamped() {
let mut c = carousel(3);
c.motion = Motion::Sliding {
fraction: WHOLE,
from_ms: 0,
};
c.set_current(2);
assert_eq!(c.current(), 2);
assert_eq!(c.motion, Motion::Still);
c.set_current(99);
assert_eq!(c.current(), 2, "out of range does nothing");
}
}