1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
//! `Carousel` / `PageView` (D115/Phase 32 Step 1) — full-width swipeable
//! pages, one visible at a time, with eased snap transitions and an
//! indicator-dot row.
//!
//! Gesture model (reuses the ScrollView machinery, D101/D108): the widget's
//! per-node [`rosace_scroll::ScrollController`] accumulates the horizontal
//! drag streamed through `ctx.on_press_at`; on release (`pressed()`
//! true→false, the same transition `ScrollView` keys momentum off) the
//! accumulated distance either snaps to the neighboring page (past
//! [`SWIPE_THRESHOLD`]) or springs back. Page position eases via
//! `ctx.animate_to`, the theme-global animation policy.
//!
//! Controlled or uncontrolled: pass `.page(Atom<usize>)` to own the current
//! page in app state (swipes write it back); without it the controller's
//! otherwise-unused `offset[1]` slot stores the page per render-tree node.
use rosace_core::types::{Point, Rect, Size};
use rosace_render::{Color, DrawCommand};
use rosace_state::Atom;
use super::{avail_w, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget, intersect_rect};
/// Horizontal drag distance (logical px) past which a release snaps to the
/// neighboring page instead of springing back.
const SWIPE_THRESHOLD: f32 = 60.0;
/// Indicator dot radius (logical px).
const DOT_RADIUS: f32 = 3.0;
/// Center-to-center spacing between indicator dots (logical px).
const DOT_SPACING: f32 = 14.0;
/// Gap between the dot row and the bottom edge (logical px).
const DOT_BOTTOM_MARGIN: f32 = 10.0;
/// Pure snap decision: which page a drag of `drag_dx` px releases onto.
/// Dragging left (negative dx) advances; dragging right goes back; anything
/// within `threshold` stays put. Always clamped to `0..page_count`.
fn snap_page(current: usize, drag_dx: f32, page_count: usize, threshold: f32) -> usize {
if page_count == 0 {
return 0;
}
let last = page_count - 1;
if drag_dx <= -threshold && current < last {
current + 1
} else if drag_dx >= threshold && current > 0 {
current - 1
} else {
current.min(last)
}
}
/// A swipeable page container: every child is one full-width page.
pub struct Carousel {
children: Vec<BoxedWidget>,
/// Controlled current page; `None` = per-node internal state.
page: Option<Atom<usize>>,
height: f32,
indicator: bool,
indicator_color: Option<Color>,
}
/// Flutter-familiar alias — a `PageView` IS a [`Carousel`].
pub type PageView = Carousel;
impl Carousel {
/// An empty carousel — add pages with [`Carousel::child`].
pub fn new() -> Self {
Self {
children: Vec::new(),
page: None,
height: 200.0,
indicator: true,
indicator_color: None,
}
}
/// Append one page.
pub fn child(mut self, w: impl Widget + 'static) -> Self {
self.children.push(Box::new(w));
self
}
/// Append several pages.
pub fn children(mut self, ws: Vec<BoxedWidget>) -> Self {
self.children.extend(ws);
self
}
/// `count` pages, each built by calling `builder(i)` — the same
/// convenience constructor `Grid::builder`/`ListView::builder` have, so
/// callers don't hand-build a `Vec` first. Eager, not virtualized (all
/// `count` pages build up front).
pub fn builder(count: usize, builder: impl Fn(usize) -> BoxedWidget) -> Self {
Self::new().children((0..count).map(builder).collect())
}
/// Control the current page from app state: swipes write the new index
/// back to the atom; external writes ease the carousel to that page.
pub fn page(mut self, page: Atom<usize>) -> Self { self.page = Some(page); self }
/// Fixed height in logical px (default `200.0`); width fills the parent.
pub fn height(mut self, h: f32) -> Self { self.height = h.max(0.0); self }
/// Hide the indicator dots.
pub fn no_indicator(mut self) -> Self { self.indicator = false; self }
/// Indicator dot tint — defaults to the theme's `primary` (active dot);
/// inactive dots are the same color dimmed.
pub fn indicator_color(mut self, c: Color) -> Self { self.indicator_color = Some(c); self }
/// Current page index (controlled atom, or the controller's spare
/// `offset[1]` slot when uncontrolled), clamped to the page count.
fn current_page(&self, ctrl: &rosace_scroll::ScrollController, n: usize) -> usize {
let raw = match &self.page {
Some(a) => a.get(),
None => ctrl.offset.get()[1].max(0.0) as usize,
};
raw.min(n.saturating_sub(1))
}
/// Write the page (atom or internal slot) and reset the drag distance.
fn set_page(&self, ctrl: &rosace_scroll::ScrollController, p: usize) {
if let Some(a) = &self.page {
if a.get() != p { a.set(p); }
}
ctrl.offset.set([0.0, p as f32]);
}
}
impl Default for Carousel {
fn default() -> Self { Self::new() }
}
impl Widget for Carousel {
fn children(&self) -> Children<'_> { Children::Many(&self.children) }
fn layout(&self, ctx: &LayoutCtx) -> Size {
ctx.constraints.constrain(Size { width: avail_w(ctx.constraints), height: self.height })
}
fn paint(&self, ctx: &mut PaintCtx) {
// Hoisted theme reads (the borrow must end before mutable painting).
let dot_active = self
.indicator_color
.unwrap_or_else(|| ctx.tc(ctx.theme.colors.primary));
let dot_inactive = Color::rgba(dot_active.r, dot_active.g, dot_active.b, 80);
let r = ctx.rect;
let n = self.children.len();
let ctrl = ctx.scroll_controller();
// Swipe input — always registered (interactive-by-identity): a drag
// over the carousel must never fall through to pan a scroll view
// behind it, wired pages or not.
let drag_ctrl = ctrl.clone();
ctx.on_press_at(move |x, y| {
let (dx, _) = drag_ctrl.drag_delta(x, y);
if dx != 0.0 {
let o = drag_ctrl.offset.get();
drag_ctrl.offset.set([o[0] + dx, o[1]]);
}
});
// Trackpad two-finger swipe (Phase 32 bug fix, user-reported):
// register as an X-axis scroll target so horizontal wheel deltas
// route HERE (the render tree's axis-aware routing sends the
// dominant-vertical gesture to the outer ScrollView and the
// dominant-horizontal one to us — a carousel no longer loses the
// gesture to the page scroll behind it). Deltas accumulate into
// the same drag offset the pointer path uses; the release
// equivalent is the controller's wheel-idle grace (there is no
// MouseUp in a wheel gesture), handled below.
let wheel_ctrl = ctrl.clone();
ctx.register_scroll_target(r, super::ScrollAxes::X, std::sync::Arc::new(move |dx, _dy| {
let o = wheel_ctrl.offset.get();
// Natural-scroll convention: content follows the fingers, the
// same negation the pointer drag already applies.
wheel_ctrl.offset.set([o[0] - dx, o[1]]);
wheel_ctrl.mark_wheel_active();
}));
if n == 0 {
return;
}
// Release detection: pressed() true→false is the drag's end (the
// same transition ScrollView keys its momentum hand-off on).
let is_pressed = ctx.pressed();
let was_pressed = ctrl.was_pressed();
let mut cur = self.current_page(&ctrl, n);
if !is_pressed && was_pressed {
let dx = ctrl.offset.get()[0];
// Seed the eased value to the CURRENT visual position (old page
// minus the live finger offset) before retargeting — otherwise
// `animate_to` below starts from the stale pre-drag page and the
// drag offset vanishes in the same frame, popping the page
// instead of continuing smoothly from the finger (user-reported
// flicker on release, most visible dragging backward).
ctx.set_anim(cur as f32 - dx / r.size.width);
cur = snap_page(cur, dx, n, SWIPE_THRESHOLD);
self.set_page(&ctrl, cur);
ctrl.end_drag();
}
ctrl.set_was_pressed(is_pressed);
// Wheel-gesture release: once the trackpad goes quiet past the
// grace window, snap exactly like a pointer release would.
if !is_pressed {
let dt = rosace_animate::frame_dt().max(0.0001);
ctrl.advance_wheel_idle(dt);
let dx = ctrl.offset.get()[0];
if dx != 0.0 {
if !ctrl.wheel_recently_active() {
ctx.set_anim(cur as f32 - dx / r.size.width);
cur = snap_page(cur, dx, n, SWIPE_THRESHOLD);
self.set_page(&ctrl, cur);
ctrl.offset.set([0.0, ctrl.offset.get()[1]]);
ctrl.end_drag();
} else {
// Keep frames coming while the gesture settles.
super::request_animation();
}
}
}
ctx.semantics(
super::Semantics::new(rosace_core::Role::List)
.label("carousel")
.value(format!("page {} of {}", cur + 1, n)),
);
// Eased page position + live finger offset while dragging.
let eased = ctx.animate_to(cur as f32, 0.0);
let drag_dx = ctrl.offset.get()[0];
let pw = r.size.width;
// Pages, clipped to the viewport (only near-visible ones painted).
ctx.record(DrawCommand::PushClip { rect: r });
let effective_clip = ctx.clip_rect
.and_then(|parent| intersect_rect(parent, r))
.unwrap_or(r);
for (i, child) in self.children.iter().enumerate() {
let x = r.origin.x + (i as f32 - eased) * pw + drag_dx;
if x + pw <= r.origin.x || x >= r.origin.x + pw {
continue; // fully off-screen
}
let page_rect = Rect { origin: Point { x, y: r.origin.y }, size: r.size };
let mut child_ctx = ctx.child(page_rect);
child_ctx.clip_rect = Some(effective_clip);
child.paint(&mut child_ctx);
}
ctx.record(DrawCommand::PopClip);
// Indicator dots, bottom-center over the content.
if self.indicator && n > 1 {
let total_w = DOT_SPACING * (n - 1) as f32;
let x0 = r.origin.x + (r.size.width - total_w) / 2.0;
let cy = r.origin.y + r.size.height - DOT_BOTTOM_MARGIN - DOT_RADIUS;
for i in 0..n {
let color = if i == cur { dot_active } else { dot_inactive };
ctx.fill_circle(
Point { x: x0 + i as f32 * DOT_SPACING, y: cy },
DOT_RADIUS,
color,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rosace_layout::Constraints;
/// A page reporting a fixed size regardless of constraints.
struct Page;
impl Widget for Page {
fn layout(&self, _ctx: &LayoutCtx) -> Size { Size { width: 10.0, height: 10.0 } }
fn paint(&self, _ctx: &mut PaintCtx) {}
}
fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
(rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
}
#[test]
fn carousel_fills_the_width_at_its_configured_height() {
let c = Carousel::new().height(240.0).child(Page).child(Page);
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(390.0, 800.0), &font, &theme);
let size = c.layout(&ctx);
assert_eq!((size.width, size.height), (390.0, 240.0));
}
#[test]
fn default_height_is_200() {
let c = Carousel::new().child(Page);
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(320.0, 800.0), &font, &theme);
assert_eq!(c.layout(&ctx).height, 200.0);
}
#[test]
fn snap_advances_past_the_threshold_and_springs_back_within_it() {
// Left drag past threshold advances.
assert_eq!(snap_page(0, -80.0, 3, 60.0), 1);
// Right drag past threshold goes back.
assert_eq!(snap_page(2, 80.0, 3, 60.0), 1);
// Within the threshold: stays put.
assert_eq!(snap_page(1, -40.0, 3, 60.0), 1);
assert_eq!(snap_page(1, 40.0, 3, 60.0), 1);
}
#[test]
fn snap_clamps_at_both_ends() {
assert_eq!(snap_page(0, 200.0, 3, 60.0), 0); // no page before first
assert_eq!(snap_page(2, -200.0, 3, 60.0), 2); // no page after last
assert_eq!(snap_page(0, -200.0, 0, 60.0), 0); // empty carousel
assert_eq!(snap_page(9, -10.0, 3, 60.0), 2); // out-of-range current clamps
}
}