Skip to main content

rosace_widgets/tree/
carousel.rs

1//! `Carousel` / `PageView` (D115/Phase 32 Step 1) — full-width swipeable
2//! pages, one visible at a time, with eased snap transitions and an
3//! indicator-dot row.
4//!
5//! Gesture model (reuses the ScrollView machinery, D101/D108): the widget's
6//! per-node [`rosace_scroll::ScrollController`] accumulates the horizontal
7//! drag streamed through `ctx.on_press_at`; on release (`pressed()`
8//! true→false, the same transition `ScrollView` keys momentum off) the
9//! accumulated distance either snaps to the neighboring page (past
10//! [`SWIPE_THRESHOLD`]) or springs back. Page position eases via
11//! `ctx.animate_to`, the theme-global animation policy.
12//!
13//! Controlled or uncontrolled: pass `.page(Atom<usize>)` to own the current
14//! page in app state (swipes write it back); without it the controller's
15//! otherwise-unused `offset[1]` slot stores the page per render-tree node.
16
17use 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
23/// Horizontal drag distance (logical px) past which a release snaps to the
24/// neighboring page instead of springing back.
25const SWIPE_THRESHOLD: f32 = 60.0;
26
27/// Indicator dot radius (logical px).
28const DOT_RADIUS: f32 = 3.0;
29/// Center-to-center spacing between indicator dots (logical px).
30const DOT_SPACING: f32 = 14.0;
31/// Gap between the dot row and the bottom edge (logical px).
32const DOT_BOTTOM_MARGIN: f32 = 10.0;
33
34/// Pure snap decision: which page a drag of `drag_dx` px releases onto.
35/// Dragging left (negative dx) advances; dragging right goes back; anything
36/// within `threshold` stays put. Always clamped to `0..page_count`.
37fn 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
51/// A swipeable page container: every child is one full-width page.
52pub struct Carousel {
53    children: Vec<BoxedWidget>,
54    /// Controlled current page; `None` = per-node internal state.
55    page: Option<Atom<usize>>,
56    height: f32,
57    indicator: bool,
58    indicator_color: Option<Color>,
59}
60
61/// Flutter-familiar alias — a `PageView` IS a [`Carousel`].
62pub type PageView = Carousel;
63
64impl Carousel {
65    /// An empty carousel — add pages with [`Carousel::child`].
66    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    /// Append one page.
76    pub fn child(mut self, w: impl Widget + 'static) -> Self {
77        self.children.push(Box::new(w));
78        self
79    }
80    /// Append several pages.
81    pub fn children(mut self, ws: Vec<BoxedWidget>) -> Self {
82        self.children.extend(ws);
83        self
84    }
85    /// `count` pages, each built by calling `builder(i)` — the same
86    /// convenience constructor `Grid::builder`/`ListView::builder` have, so
87    /// callers don't hand-build a `Vec` first. Eager, not virtualized (all
88    /// `count` pages build up front).
89    pub fn builder(count: usize, builder: impl Fn(usize) -> BoxedWidget) -> Self {
90        Self::new().children((0..count).map(builder).collect())
91    }
92    /// Control the current page from app state: swipes write the new index
93    /// back to the atom; external writes ease the carousel to that page.
94    pub fn page(mut self, page: Atom<usize>) -> Self { self.page = Some(page); self }
95    /// Fixed height in logical px (default `200.0`); width fills the parent.
96    pub fn height(mut self, h: f32) -> Self { self.height = h.max(0.0); self }
97    /// Hide the indicator dots.
98    pub fn no_indicator(mut self) -> Self { self.indicator = false; self }
99    /// Indicator dot tint — defaults to the theme's `primary` (active dot);
100    /// inactive dots are the same color dimmed.
101    pub fn indicator_color(mut self, c: Color) -> Self { self.indicator_color = Some(c); self }
102
103    /// Current page index (controlled atom, or the controller's spare
104    /// `offset[1]` slot when uncontrolled), clamped to the page count.
105    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    /// Write the page (atom or internal slot) and reset the drag distance.
114    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        // Hoisted theme reads (the borrow must end before mutable painting).
135        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        // Swipe input — always registered (interactive-by-identity): a drag
145        // over the carousel must never fall through to pan a scroll view
146        // behind it, wired pages or not.
147        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        // Trackpad two-finger swipe (Phase 32 bug fix, user-reported):
157        // register as an X-axis scroll target so horizontal wheel deltas
158        // route HERE (the render tree's axis-aware routing sends the
159        // dominant-vertical gesture to the outer ScrollView and the
160        // dominant-horizontal one to us — a carousel no longer loses the
161        // gesture to the page scroll behind it). Deltas accumulate into
162        // the same drag offset the pointer path uses; the release
163        // equivalent is the controller's wheel-idle grace (there is no
164        // MouseUp in a wheel gesture), handled below.
165        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            // Natural-scroll convention: content follows the fingers, the
169            // same negation the pointer drag already applies.
170            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        // Release detection: pressed() true→false is the drag's end (the
179        // same transition ScrollView keys its momentum hand-off on).
180        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            // Seed the eased value to the CURRENT visual position (old page
186            // minus the live finger offset) before retargeting — otherwise
187            // `animate_to` below starts from the stale pre-drag page and the
188            // drag offset vanishes in the same frame, popping the page
189            // instead of continuing smoothly from the finger (user-reported
190            // flicker on release, most visible dragging backward).
191            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        // Wheel-gesture release: once the trackpad goes quiet past the
199        // grace window, snap exactly like a pointer release would.
200        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                    // Keep frames coming while the gesture settles.
213                    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        // Eased page position + live finger offset while dragging.
225        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        // Pages, clipped to the viewport (only near-visible ones painted).
230        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; // fully off-screen
238            }
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        // Indicator dots, bottom-center over the content.
247        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    /// A page reporting a fixed size regardless of constraints.
269    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        // Left drag past threshold advances.
299        assert_eq!(snap_page(0, -80.0, 3, 60.0), 1);
300        // Right drag past threshold goes back.
301        assert_eq!(snap_page(2, 80.0, 3, 60.0), 1);
302        // Within the threshold: stays put.
303        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);   // no page before first
310        assert_eq!(snap_page(2, -200.0, 3, 60.0), 2);  // no page after last
311        assert_eq!(snap_page(0, -200.0, 0, 60.0), 0);  // empty carousel
312        assert_eq!(snap_page(9, -10.0, 3, 60.0), 2);   // out-of-range current clamps
313    }
314}