1use frus_core::{Color, Curve, Path, Point, Rect, Scene, Size};
28
29#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
31pub enum GlowEdge {
32 Top,
34 Bottom,
36 Left,
38 Right,
40}
41
42impl GlowEdge {
43 pub const ALL: [GlowEdge; 4] = [
45 GlowEdge::Top,
46 GlowEdge::Bottom,
47 GlowEdge::Left,
48 GlowEdge::Right,
49 ];
50
51 fn index(self) -> usize {
52 match self {
53 GlowEdge::Top => 0,
54 GlowEdge::Bottom => 1,
55 GlowEdge::Left => 2,
56 GlowEdge::Right => 3,
57 }
58 }
59
60 fn vertical(self) -> bool {
62 matches!(self, GlowEdge::Top | GlowEdge::Bottom)
63 }
64}
65
66#[derive(Copy, Clone, Debug, PartialEq, Eq)]
68enum GlowState {
69 Idle,
71 Absorb,
73 Pull,
75 Recede,
77}
78
79const RECEDE_TIME: f32 = 0.600;
81const PULL_TIME: f32 = 0.167;
83const PULL_HOLD_TIME: f32 = 0.167;
85const PULL_DECAY_TIME: f32 = 2.000;
87const CROSS_AXIS_HALF_TIME: f32 = 1.0 / 60.0;
89
90const MAX_OPACITY: f32 = 0.5;
92const PULL_OPACITY_GLOW_FACTOR: f32 = 0.8;
94const VELOCITY_GLOW_FACTOR: f32 = 0.000_06;
96const WIDTH_TO_HEIGHT_FACTOR: f32 = 0.75 * (2.0 - 1.732_050_8);
98const MIN_ABSORB_VELOCITY: f32 = 100.0;
100const MAX_ABSORB_VELOCITY: f32 = 10_000.0;
101
102#[derive(Copy, Clone, Debug)]
109pub struct OverscrollGlow {
110 state: GlowState,
111 elapsed: f32,
113 duration: f32,
114 opacity_from: f32,
115 opacity_to: f32,
116 size_from: f32,
117 size_to: f32,
118 displacement: f32,
121 displacement_target: f32,
122 pull_distance: f32,
125 hold_left: Option<f32>,
128}
129
130impl Default for OverscrollGlow {
131 fn default() -> Self {
132 Self {
133 state: GlowState::Idle,
134 elapsed: 0.0,
135 duration: 0.0,
136 opacity_from: 0.0,
137 opacity_to: 0.0,
138 size_from: 0.0,
139 size_to: 0.0,
140 displacement: 0.5,
141 displacement_target: 0.5,
142 pull_distance: 0.0,
143 hold_left: None,
144 }
145 }
146}
147
148impl OverscrollGlow {
149 fn t(&self) -> f32 {
151 if self.duration <= 0.0 {
152 return 1.0;
153 }
154 Curve::Decelerate.transform((self.elapsed / self.duration).clamp(0.0, 1.0))
155 }
156
157 pub fn opacity(&self) -> f32 {
159 self.opacity_from + (self.opacity_to - self.opacity_from) * self.t()
160 }
161
162 pub fn size(&self) -> f32 {
164 self.size_from + (self.size_to - self.size_from) * self.t()
165 }
166
167 pub fn is_idle(&self) -> bool {
169 self.state == GlowState::Idle
170 }
171
172 pub fn absorb_impact(&mut self, velocity: f32) {
177 let velocity = velocity
178 .abs()
179 .clamp(MIN_ABSORB_VELOCITY, MAX_ABSORB_VELOCITY);
180 self.hold_left = None;
181 self.opacity_from = if self.state == GlowState::Idle {
184 0.3
185 } else {
186 self.opacity()
187 };
188 self.opacity_to = (velocity * VELOCITY_GLOW_FACTOR).clamp(self.opacity_from, MAX_OPACITY);
189 self.size_from = self.size();
190 self.size_to = (0.025 + 7.5e-7 * velocity * velocity).min(1.0);
191 self.duration = (0.15 + velocity * 0.02) / 1000.0;
193 self.elapsed = 0.0;
194 self.displacement = 0.5;
195 self.state = GlowState::Absorb;
196 }
197
198 pub fn pull(&mut self, overscroll: f32, extent: f32, cross_offset: f32, cross_extent: f32) {
204 let extent = extent.max(1.0);
205 let cross_extent = cross_extent.max(1.0);
206 self.hold_left = Some(PULL_HOLD_TIME);
207 self.pull_distance += overscroll.abs() / 200.0;
209 self.opacity_from = self.opacity();
210 self.opacity_to = (self.opacity_from
211 + overscroll.abs() / extent * PULL_OPACITY_GLOW_FACTOR)
212 .min(MAX_OPACITY);
213 let height = extent.min(cross_extent * WIDTH_TO_HEIGHT_FACTOR);
214 self.size_from = self.size();
215 self.size_to =
217 (1.0 - 1.0 / (0.7 * (self.pull_distance * height).sqrt())).max(self.size_from);
218 self.displacement_target = (cross_offset / cross_extent).clamp(0.0, 1.0);
219 self.duration = PULL_TIME;
220 if self.state != GlowState::Pull {
221 self.elapsed = 0.0;
222 self.state = GlowState::Pull;
223 }
224 }
225
226 pub fn scroll_end(&mut self) {
228 if self.state == GlowState::Pull {
229 self.recede(RECEDE_TIME);
230 }
231 }
232
233 fn recede(&mut self, duration: f32) {
235 if matches!(self.state, GlowState::Recede | GlowState::Idle) {
236 return;
237 }
238 self.hold_left = None;
239 self.opacity_from = self.opacity();
240 self.opacity_to = 0.0;
241 self.size_from = self.size();
242 self.size_to = 0.0;
243 self.duration = duration;
244 self.elapsed = 0.0;
245 self.state = GlowState::Recede;
246 }
247
248 pub fn advance(&mut self, dt: f32) -> bool {
251 if self.state == GlowState::Idle {
252 return false;
253 }
254 self.elapsed += dt;
255 if (self.displacement_target - self.displacement).abs() > 1e-3 {
258 let decay = 2f32.powf(-dt / CROSS_AXIS_HALF_TIME);
259 self.displacement =
260 self.displacement_target - (self.displacement_target - self.displacement) * decay;
261 } else {
262 self.displacement = self.displacement_target;
263 }
264 if let Some(left) = self.hold_left.as_mut() {
266 *left -= dt;
267 if *left <= 0.0 {
268 self.hold_left = None;
269 self.recede(PULL_DECAY_TIME);
270 }
271 }
272 if self.elapsed >= self.duration {
273 match self.state {
274 GlowState::Absorb => self.recede(RECEDE_TIME),
276 GlowState::Recede => {
277 self.state = GlowState::Idle;
278 self.pull_distance = 0.0;
279 self.opacity_from = 0.0;
280 self.opacity_to = 0.0;
281 self.size_from = 0.0;
282 self.size_to = 0.0;
283 return false;
284 }
285 GlowState::Pull | GlowState::Idle => {}
287 }
288 }
289 true
290 }
291
292 pub fn paint(&self, viewport: Rect, edge: GlowEdge, color: Color, scene: &mut Scene) {
298 let opacity = self.opacity();
299 if opacity <= 0.001 || self.size() <= 0.0 {
300 return;
301 }
302 let (along, into) = if edge.vertical() {
305 (viewport.width, viewport.height)
306 } else {
307 (viewport.height, viewport.width)
308 };
309 if along <= 0.0 || into <= 0.0 {
310 return;
311 }
312
313 let base_scale = if along > into { into / along } else { 1.0 };
316 let radius = along * 1.5;
317 let band = into.min(along * WIDTH_TO_HEIGHT_FACTOR);
318 let scale_y = self.size() * base_scale;
319 let center_along = (along / 2.0) * (0.5 + self.displacement);
320 let center_into = (band - radius) * scale_y;
323 let oval = Rect::new(
324 center_along - radius,
325 center_into - radius * scale_y,
326 radius * 2.0,
327 radius * 2.0 * scale_y,
328 );
329
330 let map = |r: Rect| -> Rect {
333 match edge {
334 GlowEdge::Top => Rect::new(viewport.x + r.x, viewport.y + r.y, r.width, r.height),
335 GlowEdge::Bottom => Rect::new(
336 viewport.x + r.x,
337 viewport.y + viewport.height - r.y - r.height,
338 r.width,
339 r.height,
340 ),
341 GlowEdge::Left => Rect::new(viewport.x + r.y, viewport.y + r.x, r.height, r.width),
342 GlowEdge::Right => Rect::new(
343 viewport.x + viewport.width - r.y - r.height,
344 viewport.y + r.x,
345 r.height,
346 r.width,
347 ),
348 }
349 };
350
351 let band_rect = map(Rect::new(0.0, 0.0, along, band));
352 let previous = scene.current_clip();
353 scene.set_clip(previous.intersect(band_rect));
354 let inner = ((radius - band) / radius).clamp(0.0, 0.999);
371 let arc = map(oval);
372 scene.fill_path_radial(
373 &Path::oval(arc),
374 color.fade(opacity),
375 color.fade(0.0),
376 Point::new(arc.x + arc.width / 2.0, arc.y + arc.height / 2.0),
377 Size::new(arc.width / 2.0, arc.height / 2.0),
378 inner,
379 );
380 scene.set_clip(previous);
381 }
382}
383
384#[derive(Copy, Clone, Debug, Default)]
390pub struct ScrollGlows([OverscrollGlow; 4]);
391
392impl ScrollGlows {
393 pub fn edge(&self, edge: GlowEdge) -> &OverscrollGlow {
395 &self.0[edge.index()]
396 }
397
398 pub fn edge_mut(&mut self, edge: GlowEdge) -> &mut OverscrollGlow {
400 &mut self.0[edge.index()]
401 }
402
403 pub fn is_idle(&self) -> bool {
405 self.0.iter().all(OverscrollGlow::is_idle)
406 }
407
408 pub fn advance(&mut self, dt: f32) -> bool {
410 let mut animating = false;
411 for glow in &mut self.0 {
412 animating |= glow.advance(dt);
413 }
414 animating
415 }
416
417 pub fn scroll_end(&mut self) {
419 for glow in &mut self.0 {
420 glow.scroll_end();
421 }
422 }
423
424 pub fn paint(&self, viewport: Rect, color: Color, scene: &mut Scene) {
426 for edge in GlowEdge::ALL {
427 self.edge(edge).paint(viewport, edge, color, scene);
428 }
429 }
430}
431
432pub fn edge_for(vertical: bool, delta: f32) -> GlowEdge {
435 match (vertical, delta < 0.0) {
436 (true, true) => GlowEdge::Top,
437 (true, false) => GlowEdge::Bottom,
438 (false, true) => GlowEdge::Left,
439 (false, false) => GlowEdge::Right,
440 }
441}
442
443pub fn cross_axis(viewport: Rect, edge: GlowEdge, point: Point) -> (f32, f32) {
446 if edge.vertical() {
447 (
448 (point.x - viewport.x).clamp(0.0, viewport.width),
449 viewport.width,
450 )
451 } else {
452 (
453 (point.y - viewport.y).clamp(0.0, viewport.height),
454 viewport.height,
455 )
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn a_fresh_glow_shows_nothing() {
465 let glow = OverscrollGlow::default();
466 assert!(glow.is_idle());
467 assert_eq!(glow.opacity(), 0.0);
468 let mut glow = glow;
469 assert!(!glow.advance(0.016), "an idle glow does not animate");
470 }
471
472 #[test]
473 fn an_impact_flashes_then_fades_to_nothing() {
474 let mut glow = OverscrollGlow::default();
475 glow.absorb_impact(3000.0);
476 assert!(!glow.is_idle());
477 assert!(glow.opacity() > 0.0, "visible at once");
478 let mut frames = 0;
480 while glow.advance(1.0 / 60.0) {
481 frames += 1;
482 assert!(frames < 300, "the flash never finished");
483 }
484 assert!(glow.is_idle());
485 assert_eq!(glow.opacity(), 0.0);
486 }
487
488 #[test]
489 fn a_faster_impact_glows_harder() {
490 let peak = |velocity: f32| {
491 let mut glow = OverscrollGlow::default();
492 glow.absorb_impact(velocity);
493 let mut peak: f32 = 0.0;
494 loop {
495 peak = peak.max(glow.opacity() * glow.size());
496 if !glow.advance(1.0 / 60.0) {
497 break;
498 }
499 }
500 peak
501 };
502 assert!(
503 peak(6000.0) > peak(600.0),
504 "the harder landing should show more"
505 );
506 }
507
508 #[test]
509 fn the_absorbed_opacity_is_capped() {
510 let mut glow = OverscrollGlow::default();
511 glow.absorb_impact(1e9); let mut peak: f32 = 0.0;
513 loop {
514 peak = peak.max(glow.opacity());
515 if !glow.advance(1.0 / 60.0) {
516 break;
517 }
518 }
519 assert!(peak <= MAX_OPACITY + 1e-4, "peak = {peak}");
520 }
521
522 #[test]
523 fn a_longer_pull_glows_more_than_a_short_one() {
524 let after = |pulls: usize| {
525 let mut glow = OverscrollGlow::default();
526 for _ in 0..pulls {
527 glow.pull(12.0, 800.0, 200.0, 400.0);
528 glow.advance(1.0 / 60.0);
529 }
530 glow.size()
531 };
532 assert!(after(20) > after(2), "the pull must accumulate");
533 }
534
535 #[test]
536 fn letting_go_of_a_pull_fades_it() {
537 let mut glow = OverscrollGlow::default();
538 glow.pull(40.0, 800.0, 200.0, 400.0);
539 glow.advance(1.0 / 60.0);
540 assert!(glow.opacity() > 0.0);
541 glow.scroll_end();
542 let mut frames = 0;
543 while glow.advance(1.0 / 60.0) {
544 frames += 1;
545 assert!(frames < 200, "the fade never finished");
546 }
547 assert!(glow.is_idle());
548 }
549
550 #[test]
551 fn a_pull_left_alone_decays_on_its_own() {
552 let mut glow = OverscrollGlow::default();
555 glow.pull(40.0, 800.0, 200.0, 400.0);
556 for _ in 0..20 {
558 glow.advance(1.0 / 60.0);
559 }
560 let held = glow.opacity();
561 for _ in 0..60 {
562 glow.advance(1.0 / 60.0);
563 }
564 assert!(glow.opacity() < held, "it should be fading by itself");
565 }
566
567 #[test]
568 fn the_arc_slides_towards_the_finger() {
569 let mut glow = OverscrollGlow::default();
570 glow.pull(20.0, 800.0, 400.0, 400.0);
572 let start = glow.displacement;
573 assert_eq!(start, 0.5, "it starts centred");
574 for _ in 0..10 {
575 glow.advance(1.0 / 60.0);
576 }
577 assert!(
578 glow.displacement > start && glow.displacement <= 1.0,
579 "displacement = {}",
580 glow.displacement
581 );
582 }
583
584 #[test]
585 fn a_glow_paints_inside_its_edge_band() {
586 let viewport = Rect::new(10.0, 20.0, 400.0, 600.0);
587 for edge in GlowEdge::ALL {
588 let mut glow = OverscrollGlow::default();
589 glow.absorb_impact(4000.0);
590 glow.advance(0.05);
591 let mut scene = Scene::new();
592 scene.set_clip(Rect::new(0.0, 0.0, 1000.0, 1000.0));
593 glow.paint(viewport, edge, Color::rgb(0.0, 1.0, 0.0), &mut scene);
594 assert_eq!(scene.len(), 1, "{edge:?} should paint exactly one shape");
595 let bounds = scene.primitives()[0].bounds();
596 let clip = match scene.primitives()[0] {
598 frus_core::Primitive::Path { clip, .. } => clip,
599 _ => panic!("the glow is a filled path"),
600 };
601 assert!(
602 clip.width <= viewport.width + 1.0 && clip.height <= viewport.height + 1.0,
603 "{edge:?} band {clip:?} escapes the viewport"
604 );
605 let touches_edge = match edge {
606 GlowEdge::Top => (clip.y - viewport.y).abs() < 1.0,
607 GlowEdge::Bottom => {
608 ((clip.y + clip.height) - (viewport.y + viewport.height)).abs() < 1.0
609 }
610 GlowEdge::Left => (clip.x - viewport.x).abs() < 1.0,
611 GlowEdge::Right => {
612 ((clip.x + clip.width) - (viewport.x + viewport.width)).abs() < 1.0
613 }
614 };
615 assert!(touches_edge, "{edge:?} band {clip:?} is not on its edge");
616 let _ = bounds;
617 }
618 }
619
620 #[test]
621 fn an_idle_glow_paints_nothing() {
622 let mut scene = Scene::new();
623 OverscrollGlow::default().paint(
624 Rect::new(0.0, 0.0, 100.0, 100.0),
625 GlowEdge::Top,
626 Color::rgb(1.0, 1.0, 1.0),
627 &mut scene,
628 );
629 assert!(scene.is_empty());
630 }
631
632 #[test]
633 fn the_refused_direction_picks_the_edge() {
634 assert_eq!(edge_for(true, -5.0), GlowEdge::Top);
635 assert_eq!(edge_for(true, 5.0), GlowEdge::Bottom);
636 assert_eq!(edge_for(false, -5.0), GlowEdge::Left);
637 assert_eq!(edge_for(false, 5.0), GlowEdge::Right);
638 }
639
640 #[test]
641 fn the_cross_axis_is_measured_along_the_edge() {
642 let viewport = Rect::new(10.0, 20.0, 400.0, 600.0);
643 let (offset, extent) = cross_axis(viewport, GlowEdge::Top, Point::new(210.0, 300.0));
644 assert_eq!((offset, extent), (200.0, 400.0));
645 let (offset, extent) = cross_axis(viewport, GlowEdge::Left, Point::new(210.0, 320.0));
646 assert_eq!((offset, extent), (300.0, 600.0));
647 let (offset, _) = cross_axis(viewport, GlowEdge::Top, Point::new(-500.0, 0.0));
649 assert_eq!(offset, 0.0);
650 }
651
652 #[test]
653 fn all_four_edges_are_addressable_and_start_quiet() {
654 let mut glows = ScrollGlows::default();
655 assert!(glows.is_idle());
656 glows.edge_mut(GlowEdge::Bottom).absorb_impact(2000.0);
657 assert!(!glows.is_idle());
658 assert!(glows.edge(GlowEdge::Top).is_idle(), "only one edge reacts");
659 while glows.advance(1.0 / 60.0) {}
660 assert!(glows.is_idle());
661 }
662}