use alloc::vec::Vec;
use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Size};
use crate::motion::Wake;
use crate::widget::{Animation, Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::image::{Fit, Image};
use crate::widgets::style::{focus_ring, interactive_pair};
const SLIDE_MS: u64 = 250;
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 Phase {
Still,
Dragging { fraction: i32 },
Sliding { fraction: i32, from_ms: u64 },
}
#[derive(Clone, Debug)]
pub struct Carousel<M> {
pages: Vec<Image>,
current: usize,
phase: Phase,
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,
phase: Phase::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,
phase: Phase::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.phase = Phase::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 advance_wake(&self, now_ms: u64) -> Wake {
match self.advance_ms {
Some(interval) => Wake::At(now_ms.saturating_add(interval)),
None => Wake::Never,
}
}
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.phase {
Phase::Still => 0,
Phase::Dragging { fraction } => fraction,
Phase::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.phase = Phase::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 describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
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 Pen<'_>, 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.phase = Phase::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.phase == (Phase::Dragging { fraction }) {
return Handled::No;
}
self.phase = Phase::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.phase {
Phase::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.phase = Phase::Sliding {
fraction,
from_ms: ctx.now_ms,
};
ctx.request_animation();
} else {
self.phase = Phase::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.phase {
Phase::Sliding { from_ms, .. } => {
if now_ms.saturating_sub(from_ms) >= SLIDE_MS {
self.phase = Phase::Still;
self.held_since = now_ms;
Animation {
repaint: true,
next: self.advance_wake(now_ms),
}
} else {
Animation::MOVING
}
}
Phase::Dragging { .. } => Animation {
repaint: false,
next: self.advance_wake(now_ms),
},
Phase::Still => match self.advance_ms {
None => Animation::NONE,
Some(_) if self.pages.len() < 2 => Animation {
repaint: false,
next: self.advance_wake(now_ms),
},
Some(interval) => {
let due = self.held_since.saturating_add(interval);
if now_ms < due {
Animation::due_at(due)
} else {
self.current = self.neighbour(1);
self.phase = Phase::Sliding {
fraction: WHOLE,
from_ms: now_ms,
};
self.held_since = now_ms;
Animation::MOVING
}
}
},
}
}
fn snap(&mut self, now_ms: u64) -> Animation {
match self.phase {
Phase::Sliding { .. } => {
self.phase = Phase::Still;
self.held_since = now_ms;
Animation {
repaint: true,
next: self.advance_wake(now_ms),
}
}
Phase::Dragging { .. } => Animation {
repaint: false,
next: self.advance_wake(now_ms),
},
Phase::Still => match self.advance_ms {
None => Animation::NONE,
Some(_) if self.pages.len() < 2 => Animation {
repaint: false,
next: self.advance_wake(now_ms),
},
Some(interval) => {
let due = self.held_since.saturating_add(interval);
if now_ms < due {
Animation::due_at(due)
} else {
self.current = self.neighbour(1);
self.held_since = now_ms;
Animation {
repaint: true,
next: self.advance_wake(now_ms),
}
}
}
},
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
self.message.is_some() && self.pages.len() > 1
}
}
impl<M> Describe for Carousel<M> {
const KIND: &'static str = "carousel";
const DOC: &'static str = "Pictures shown one at a time, sliding between them.";
const GROUP: Group = Group::Media;
const ICON: &'static denise::icon::Icon = &super::icons::CAROUSEL;
const PROPERTIES: &'static [Property] = &[
Property::new(
"selected",
PropertyKind::Int {
min: 0,
max: i32::MAX,
},
"Which page is showing when the form opens; the real upper bound is the number of pictures, which a descriptor cannot see.",
),
Property::new(
"on-change",
PropertyKind::Message(Payload::Index),
"Emitted with the page a person lands on; the advance clock is silent, because a message reports what a person did.",
),
Property::new(
"auto-advance-ms",
PropertyKind::Int {
min: 500,
max: 60_000,
},
"Advance to the next page this often, on the animation clock; without it the carousel only moves when someone moves it.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour of the current page's dot and the focus ring.",
),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"selected" => Value::Int(i32::try_from(self.current()).unwrap_or(i32::MAX)),
"auto-advance-ms" => Value::Int(i32::try_from(self.advance_ms?).unwrap_or(i32::MAX)),
"role" => Value::role(self.role),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"selected" => self.set_current(value.as_index()?),
"auto-advance-ms" => {
self.advance_ms = Some(value.as_millis()?.max(SLIDE_MS * 2));
}
"role" => self.role = value.as_role()?,
"on-change" => return Err(Mismatch::Supplied),
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[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, Wake::At(9_000), "one wake, at the deadline");
let again = Widget::<usize>::animate(&mut c, 5_000);
assert_eq!(again.next, Wake::At(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, Wake::Animating, "sliding at the tree's 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, Wake::At(8_000 + SLIDE_MS + 8_000));
assert_eq!(c.phase, Phase::Still);
let mid_hold = Widget::<usize>::animate(&mut c, 8_000 + SLIDE_MS + 1_000);
assert!(!mid_hold.repaint);
assert_eq!(mid_hold.next, Wake::At(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.phase = Phase::Sliding {
fraction: WHOLE,
from_ms: 0,
};
c.set_current(2);
assert_eq!(c.current(), 2);
assert_eq!(c.phase, Phase::Still);
c.set_current(99);
assert_eq!(c.current(), 2, "out of range does nothing");
}
}