1use rosace_core::types::{Point, Rect, Size};
18use rosace_render::{Color, DrawCommand};
19use rosace_state::Atom;
20
21use super::{avail_w, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget, intersect_rect};
22
23const SWIPE_THRESHOLD: f32 = 60.0;
26
27const DOT_RADIUS: f32 = 3.0;
29const DOT_SPACING: f32 = 14.0;
31const DOT_BOTTOM_MARGIN: f32 = 10.0;
33
34fn snap_page(current: usize, drag_dx: f32, page_count: usize, threshold: f32) -> usize {
38 if page_count == 0 {
39 return 0;
40 }
41 let last = page_count - 1;
42 if drag_dx <= -threshold && current < last {
43 current + 1
44 } else if drag_dx >= threshold && current > 0 {
45 current - 1
46 } else {
47 current.min(last)
48 }
49}
50
51pub struct Carousel {
53 children: Vec<BoxedWidget>,
54 page: Option<Atom<usize>>,
56 height: f32,
57 indicator: bool,
58 indicator_color: Option<Color>,
59}
60
61pub type PageView = Carousel;
63
64impl Carousel {
65 pub fn new() -> Self {
67 Self {
68 children: Vec::new(),
69 page: None,
70 height: 200.0,
71 indicator: true,
72 indicator_color: None,
73 }
74 }
75 pub fn child(mut self, w: impl Widget + 'static) -> Self {
77 self.children.push(Box::new(w));
78 self
79 }
80 pub fn children(mut self, ws: Vec<BoxedWidget>) -> Self {
82 self.children.extend(ws);
83 self
84 }
85 pub fn builder(count: usize, builder: impl Fn(usize) -> BoxedWidget) -> Self {
90 Self::new().children((0..count).map(builder).collect())
91 }
92 pub fn page(mut self, page: Atom<usize>) -> Self { self.page = Some(page); self }
95 pub fn height(mut self, h: f32) -> Self { self.height = h.max(0.0); self }
97 pub fn no_indicator(mut self) -> Self { self.indicator = false; self }
99 pub fn indicator_color(mut self, c: Color) -> Self { self.indicator_color = Some(c); self }
102
103 fn current_page(&self, ctrl: &rosace_scroll::ScrollController, n: usize) -> usize {
106 let raw = match &self.page {
107 Some(a) => a.get(),
108 None => ctrl.offset.get()[1].max(0.0) as usize,
109 };
110 raw.min(n.saturating_sub(1))
111 }
112
113 fn set_page(&self, ctrl: &rosace_scroll::ScrollController, p: usize) {
115 if let Some(a) = &self.page {
116 if a.get() != p { a.set(p); }
117 }
118 ctrl.offset.set([0.0, p as f32]);
119 }
120}
121
122impl Default for Carousel {
123 fn default() -> Self { Self::new() }
124}
125
126impl Widget for Carousel {
127 fn children(&self) -> Children<'_> { Children::Many(&self.children) }
128
129 fn layout(&self, ctx: &LayoutCtx) -> Size {
130 ctx.constraints.constrain(Size { width: avail_w(ctx.constraints), height: self.height })
131 }
132
133 fn paint(&self, ctx: &mut PaintCtx) {
134 let dot_active = self
136 .indicator_color
137 .unwrap_or_else(|| ctx.tc(ctx.theme.colors.primary));
138 let dot_inactive = Color::rgba(dot_active.r, dot_active.g, dot_active.b, 80);
139
140 let r = ctx.rect;
141 let n = self.children.len();
142 let ctrl = ctx.scroll_controller();
143
144 let drag_ctrl = ctrl.clone();
148 ctx.on_press_at(move |x, y| {
149 let (dx, _) = drag_ctrl.drag_delta(x, y);
150 if dx != 0.0 {
151 let o = drag_ctrl.offset.get();
152 drag_ctrl.offset.set([o[0] + dx, o[1]]);
153 }
154 });
155
156 let wheel_ctrl = ctrl.clone();
166 ctx.register_scroll_target(r, super::ScrollAxes::X, std::sync::Arc::new(move |dx, _dy| {
167 let o = wheel_ctrl.offset.get();
168 wheel_ctrl.offset.set([o[0] - dx, o[1]]);
171 wheel_ctrl.mark_wheel_active();
172 }));
173
174 if n == 0 {
175 return;
176 }
177
178 let is_pressed = ctx.pressed();
181 let was_pressed = ctrl.was_pressed();
182 let mut cur = self.current_page(&ctrl, n);
183 if !is_pressed && was_pressed {
184 let dx = ctrl.offset.get()[0];
185 ctx.set_anim(cur as f32 - dx / r.size.width);
192 cur = snap_page(cur, dx, n, SWIPE_THRESHOLD);
193 self.set_page(&ctrl, cur);
194 ctrl.end_drag();
195 }
196 ctrl.set_was_pressed(is_pressed);
197
198 if !is_pressed {
201 let dt = rosace_animate::frame_dt().max(0.0001);
202 ctrl.advance_wheel_idle(dt);
203 let dx = ctrl.offset.get()[0];
204 if dx != 0.0 {
205 if !ctrl.wheel_recently_active() {
206 ctx.set_anim(cur as f32 - dx / r.size.width);
207 cur = snap_page(cur, dx, n, SWIPE_THRESHOLD);
208 self.set_page(&ctrl, cur);
209 ctrl.offset.set([0.0, ctrl.offset.get()[1]]);
210 ctrl.end_drag();
211 } else {
212 super::request_animation();
214 }
215 }
216 }
217
218 ctx.semantics(
219 super::Semantics::new(rosace_core::Role::List)
220 .label("carousel")
221 .value(format!("page {} of {}", cur + 1, n)),
222 );
223
224 let eased = ctx.animate_to(cur as f32, 0.0);
226 let drag_dx = ctrl.offset.get()[0];
227 let pw = r.size.width;
228
229 ctx.record(DrawCommand::PushClip { rect: r });
231 let effective_clip = ctx.clip_rect
232 .and_then(|parent| intersect_rect(parent, r))
233 .unwrap_or(r);
234 for (i, child) in self.children.iter().enumerate() {
235 let x = r.origin.x + (i as f32 - eased) * pw + drag_dx;
236 if x + pw <= r.origin.x || x >= r.origin.x + pw {
237 continue; }
239 let page_rect = Rect { origin: Point { x, y: r.origin.y }, size: r.size };
240 let mut child_ctx = ctx.child(page_rect);
241 child_ctx.clip_rect = Some(effective_clip);
242 child.paint(&mut child_ctx);
243 }
244 ctx.record(DrawCommand::PopClip);
245
246 if self.indicator && n > 1 {
248 let total_w = DOT_SPACING * (n - 1) as f32;
249 let x0 = r.origin.x + (r.size.width - total_w) / 2.0;
250 let cy = r.origin.y + r.size.height - DOT_BOTTOM_MARGIN - DOT_RADIUS;
251 for i in 0..n {
252 let color = if i == cur { dot_active } else { dot_inactive };
253 ctx.fill_circle(
254 Point { x: x0 + i as f32 * DOT_SPACING, y: cy },
255 DOT_RADIUS,
256 color,
257 );
258 }
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use rosace_layout::Constraints;
267
268 struct Page;
270 impl Widget for Page {
271 fn layout(&self, _ctx: &LayoutCtx) -> Size { Size { width: 10.0, height: 10.0 } }
272 fn paint(&self, _ctx: &mut PaintCtx) {}
273 }
274
275 fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
276 (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
277 }
278
279 #[test]
280 fn carousel_fills_the_width_at_its_configured_height() {
281 let c = Carousel::new().height(240.0).child(Page).child(Page);
282 let (font, theme) = test_env();
283 let ctx = LayoutCtx::new(Constraints::loose(390.0, 800.0), &font, &theme);
284 let size = c.layout(&ctx);
285 assert_eq!((size.width, size.height), (390.0, 240.0));
286 }
287
288 #[test]
289 fn default_height_is_200() {
290 let c = Carousel::new().child(Page);
291 let (font, theme) = test_env();
292 let ctx = LayoutCtx::new(Constraints::loose(320.0, 800.0), &font, &theme);
293 assert_eq!(c.layout(&ctx).height, 200.0);
294 }
295
296 #[test]
297 fn snap_advances_past_the_threshold_and_springs_back_within_it() {
298 assert_eq!(snap_page(0, -80.0, 3, 60.0), 1);
300 assert_eq!(snap_page(2, 80.0, 3, 60.0), 1);
302 assert_eq!(snap_page(1, -40.0, 3, 60.0), 1);
304 assert_eq!(snap_page(1, 40.0, 3, 60.0), 1);
305 }
306
307 #[test]
308 fn snap_clamps_at_both_ends() {
309 assert_eq!(snap_page(0, 200.0, 3, 60.0), 0); assert_eq!(snap_page(2, -200.0, 3, 60.0), 2); assert_eq!(snap_page(0, -200.0, 0, 60.0), 0); assert_eq!(snap_page(9, -10.0, 3, 60.0), 2); }
314}