1use std::fmt;
25use std::rc::Rc;
26
27use ravenrs_animation::Easing;
28use ravenrs_core::{Effect, Insets, Rect};
29use ravenrs_layout::{Align, Axis, LayoutStyle};
30
31use crate::View;
32use crate::context::{BuildCx, NodeId, Paint};
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36#[non_exhaustive]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
38pub enum ScrollRange {
39 #[default]
42 Scene,
43 Through,
46 Enter,
49 Exit,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq)]
55pub(crate) struct ScrollTrack {
56 pub top: f32,
57 pub length: f32,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl ScrollRange {
62 pub(crate) fn progress(self, frame: Rect, view: Rect, scene: Option<(Rect, ScrollTrack)>) -> f32 {
66 let ratio = |num: f32, den: f32| {
67 if den > 0.0 {
68 (num / den).clamp(0.0, 1.0)
69 } else if num >= 0.0 {
70 1.0
71 } else {
72 0.0
73 }
74 };
75 match (self, scene) {
76 (Self::Scene, Some((track, t))) => ratio(view.y() + t.top - track.y(), t.length),
77 (Self::Scene | Self::Through, _) => {
78 ratio(view.max_y() - frame.y(), view.height() + frame.height())
79 }
80 (Self::Enter, _) => {
81 let span = frame.height().min(view.height());
82 ratio(view.max_y() - frame.y(), span)
83 }
84 (Self::Exit, _) => ratio(view.y() - frame.y(), frame.height()),
85 }
86 }
87}
88
89#[derive(Clone)]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
96pub struct ScrollLink {
97 range: ScrollRange,
98 from: f32,
99 to: f32,
100 easing: Easing,
101 map: Rc<dyn Fn(f32) -> Effect>,
102}
103
104impl fmt::Debug for ScrollLink {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.debug_struct("ScrollLink")
107 .field("range", &self.range)
108 .field("from", &self.from)
109 .field("to", &self.to)
110 .field("easing", &self.easing)
111 .finish_non_exhaustive()
112 }
113}
114
115#[cfg_attr(feature = "live", ravenrs_live::expose)]
116impl ScrollLink {
117 pub fn custom(map: impl Fn(f32) -> Effect + 'static) -> Self {
119 Self { range: ScrollRange::default(), from: 0.0, to: 1.0, easing: Easing::Linear, map: Rc::new(map) }
120 }
121
122 pub fn keyframes(stops: impl IntoIterator<Item = (f32, Effect)>) -> Self {
126 let mut stops: Vec<(f32, Effect)> = stops.into_iter().collect();
127 stops.sort_by(|a, b| a.0.total_cmp(&b.0));
128 Self::custom(move |t| keyframe_at(&stops, t))
129 }
130
131 pub fn fade_in() -> Self {
133 Self::keyframes([(0.0, Effect::hidden().then(Effect::moved(0.0, 24.0))), (1.0, Effect::IDENTITY)])
134 }
135
136 pub fn fade_out() -> Self {
138 Self::keyframes([(0.0, Effect::IDENTITY), (1.0, Effect::hidden().then(Effect::moved(0.0, -24.0)))])
139 }
140
141 pub fn parallax(distance: f32) -> Self {
144 Self::custom(move |t| Effect::moved(0.0, distance * t)).easing(Easing::Linear)
145 }
146
147 pub fn grow_from(from: f32) -> Self {
149 Self::keyframes([(0.0, Effect::scaled(from)), (1.0, Effect::IDENTITY)])
150 }
151
152 pub fn range(mut self, range: ScrollRange) -> Self {
154 self.range = range;
155 self
156 }
157
158 pub fn between(mut self, from: f32, to: f32) -> Self {
161 self.from = from;
162 self.to = to;
163 self
164 }
165
166 pub fn easing(mut self, easing: Easing) -> Self {
168 self.easing = easing;
169 self
170 }
171
172 pub fn scroll_range(&self) -> ScrollRange {
174 self.range
175 }
176
177 pub fn effect_at(&self, progress: f32) -> Effect {
179 let span = self.to - self.from;
180 let t = if span.abs() <= f32::EPSILON {
181 if progress >= self.to { 1.0 } else { 0.0 }
182 } else {
183 ((progress - self.from) / span).clamp(0.0, 1.0)
184 };
185 (self.map)(self.easing.sample(t))
186 }
187}
188
189fn keyframe_at(stops: &[(f32, Effect)], t: f32) -> Effect {
190 let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Effect::IDENTITY };
191 if t <= first.0 {
192 return first.1;
193 }
194 if t >= last.0 {
195 return last.1;
196 }
197 for pair in stops.windows(2) {
198 let ((a, ea), (b, eb)) = (pair[0], pair[1]);
199 if t <= b {
200 let local = if b > a { (t - a) / (b - a) } else { 1.0 };
201 return ea.lerp(eb, local);
202 }
203 }
204 last.1
205}
206
207#[derive(Debug)]
211pub struct Sticky<C> {
212 content: C,
213 top: f32,
214}
215
216#[cfg_attr(feature = "live", ravenrs_live::expose)]
217impl<C: View> Sticky<C> {
218 pub fn new(content: C) -> Self {
220 Self { content, top: 0.0 }
221 }
222
223 pub fn top(mut self, top: f32) -> Self {
225 self.top = top;
226 self
227 }
228}
229
230impl<C: View> View for Sticky<C> {
231 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
232 let child = cx.child(&self.content);
233 let node = cx.container(
234 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
235 &[child],
236 Paint::None,
237 );
238 cx.pin(node, self.top);
239 node
240 }
241}
242
243#[derive(Debug)]
248pub struct ScrollScene<C> {
249 content: C,
250 length: f32,
251 top: f32,
252}
253
254#[cfg_attr(feature = "live", ravenrs_live::expose)]
255impl<C: View> ScrollScene<C> {
256 pub fn new(content: C) -> Self {
258 Self { content, length: 800.0, top: 0.0 }
259 }
260
261 pub fn length(mut self, length: f32) -> Self {
263 self.length = length.max(0.0);
264 self
265 }
266
267 pub fn top(mut self, top: f32) -> Self {
269 self.top = top;
270 self
271 }
272}
273
274impl<C: View> View for ScrollScene<C> {
275 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
276 let child = cx.child(&self.content);
277 let pinned = cx.container(
278 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
279 &[child],
280 Paint::None,
281 );
282 cx.pin(pinned, self.top);
283 let style = LayoutStyle {
284 padding: Insets { bottom: self.length, ..Insets::ZERO },
285 align: Align::Stretch,
286 ..LayoutStyle::stack(Axis::Vertical)
287 };
288 let track = cx.container(style, &[pinned], Paint::None);
289 cx.scroll_track(track, self.top, self.length);
290 track
291 }
292}
293
294#[derive(Debug)]
298pub struct ScrollLinked<C> {
299 content: C,
300 link: ScrollLink,
301}
302
303#[cfg_attr(feature = "live", ravenrs_live::expose)]
304impl<C: View> ScrollLinked<C> {
305 pub fn new(content: C, link: ScrollLink) -> Self {
307 Self { content, link }
308 }
309}
310
311impl<C: View> View for ScrollLinked<C> {
312 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
313 let child = cx.child(&self.content);
314 let node = cx.container(
315 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
316 &[child],
317 Paint::None,
318 );
319 cx.scroll_linked(node, self.link.clone());
320 node
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use ravenrs_core::Point;
327
328 use super::*;
329
330 fn view() -> Rect {
331 Rect::new(0.0, 0.0, 800.0, 600.0)
332 }
333
334 #[test]
335 fn through_runs_from_the_bottom_edge_to_the_top_edge() {
336 let at = |y| ScrollRange::Through.progress(Rect::new(0.0, y, 800.0, 200.0), view(), None);
337 assert_eq!(at(600.0), 0.0);
338 assert_eq!(at(-200.0), 1.0);
339 assert!((at(200.0) - 0.5).abs() < 1e-6);
340 }
341
342 #[test]
343 fn scene_progress_follows_the_track_through_its_length() {
344 let track = ScrollTrack { top: 64.0, length: 1000.0 };
345 let at = |y| {
346 ScrollRange::Scene.progress(Rect::ZERO, view(), Some((Rect::new(0.0, y, 800.0, 1600.0), track)))
347 };
348 assert_eq!(at(64.0), 0.0);
349 assert!((at(-436.0) - 0.5).abs() < 1e-6);
350 assert_eq!(at(-936.0), 1.0);
351 }
352
353 #[test]
354 fn enter_and_exit_cover_their_own_edges() {
355 let r = |y| Rect::new(0.0, y, 800.0, 200.0);
356 assert_eq!(ScrollRange::Enter.progress(r(600.0), view(), None), 0.0);
357 assert_eq!(ScrollRange::Enter.progress(r(400.0), view(), None), 1.0);
358 assert_eq!(ScrollRange::Exit.progress(r(0.0), view(), None), 0.0);
359 assert!((ScrollRange::Exit.progress(r(-100.0), view(), None) - 0.5).abs() < 1e-6);
360 }
361
362 #[test]
363 fn a_window_holds_its_ends_and_keyframes_interpolate() {
364 let link = ScrollLink::fade_in().between(0.4, 0.6);
365 assert_eq!(link.effect_at(0.1).opacity, 0.0);
366 assert_eq!(link.effect_at(0.9), Effect::IDENTITY);
367 let mid = link.effect_at(0.5);
368 assert!((mid.opacity - 0.5).abs() < 1e-6);
369 assert!((mid.offset.y - 12.0).abs() < 1e-4);
370 let stops = ScrollLink::keyframes([
371 (1.0, Effect::scaled(2.0)),
372 (0.0, Effect::IDENTITY),
373 (0.5, Effect::scaled(1.5)),
374 ]);
375 assert!((stops.effect_at(0.75).scale - 1.75).abs() < 1e-6, "stops are sorted");
376 assert_eq!(ScrollLink::keyframes([]).effect_at(0.5), Effect::IDENTITY);
377 }
378
379 #[test]
380 fn effects_compose_and_interpolate() {
381 let a = Effect::moved(0.0, 10.0).with_opacity(0.5);
382 let b = Effect::scaled(2.0).with_opacity(0.5);
383 let both = a.then(b);
384 assert_eq!(both.opacity, 0.25);
385 assert_eq!(both.scale, 2.0);
386 assert_eq!(both.offset, Point::new(0.0, 10.0));
387 assert_eq!(Effect::IDENTITY.lerp(Effect::hidden(), 0.25).opacity, 0.75);
388 }
389}