1pub const EPSILON: f64 = 1e-6;
28
29pub const AX_FALLBACK_WARNING: &str =
31 "wheel event not delivered to background app; used AX scroll bar";
32
33pub const APPROXIMATED_WARNING: &str = "scroll step approximated";
37
38pub const CONTENT_FITS_WARNING: &str = "content fits; nothing to scroll";
41
42pub const DEFAULT_STEP: f64 = 0.05;
44
45pub const LINE_POINTS: f64 = 40.0;
50
51pub const MIN_STEP: f64 = 0.005;
53
54pub const MAX_STEP: f64 = 1.0;
56
57pub const ROUNDING_TOLERANCE: f64 = 0.005;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub enum Axis {
65 Vertical,
67 Horizontal,
69}
70
71impl Axis {
72 pub fn as_str(self) -> &'static str {
74 match self {
75 Self::Vertical => "vertical",
76 Self::Horizontal => "horizontal",
77 }
78 }
79
80 pub fn limit(self, steps: i32) -> &'static str {
82 match (self, steps >= 0) {
83 (Self::Vertical, true) => "bottom",
84 (Self::Vertical, false) => "top",
85 (Self::Horizontal, true) => "right",
86 (Self::Horizontal, false) => "left",
87 }
88 }
89
90 pub fn bar_attribute(self) -> &'static str {
92 match self {
93 Self::Vertical => "AXVerticalScrollBar",
94 Self::Horizontal => "AXHorizontalScrollBar",
95 }
96 }
97
98 pub fn delta(self, dx: i32, dy: i32) -> i32 {
100 match self {
101 Self::Vertical => dy,
102 Self::Horizontal => dx,
103 }
104 }
105
106 pub fn both() -> [Self; 2] {
108 [Self::Vertical, Self::Horizontal]
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Via {
115 Event,
117 AxScrollbar,
119}
120
121impl Via {
122 pub fn as_str(self) -> &'static str {
124 match self {
125 Self::Event => "event",
126 Self::AxScrollbar => "ax_scrollbar",
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum Decision {
134 Event { before: f64, after: f64 },
136 AxScrollbar { before: f64, next: f64 },
138 AtLimit { value: f64 },
140 Unverifiable,
142}
143
144pub fn moved(before: f64, after: f64) -> bool {
146 (after - before).abs() > EPSILON
147}
148
149pub fn at_limit(value: f64, steps: i32) -> bool {
153 match steps.cmp(&0) {
154 std::cmp::Ordering::Greater => value >= 1.0 - EPSILON,
155 std::cmp::Ordering::Less => value <= EPSILON,
156 std::cmp::Ordering::Equal => false,
157 }
158}
159
160#[derive(Debug, Clone, PartialEq)]
168pub struct Step {
169 pub value: f64,
171 pub warnings: Vec<String>,
173}
174
175impl Step {
176 pub const fn measured(value: f64) -> Self {
178 Self {
179 value,
180 warnings: Vec::new(),
181 }
182 }
183
184 pub const fn approx() -> Self {
188 Self::measured(DEFAULT_STEP)
189 }
190}
191
192pub fn content_step(content: Option<f64>, visible: Option<f64>) -> Step {
211 match (content, visible) {
212 (Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 && c > v => {
213 Step::measured((LINE_POINTS / (c - v)).clamp(0.0, 1.0))
214 }
215 (Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 => Step {
216 value: 0.0,
217 warnings: vec![CONTENT_FITS_WARNING.to_string()],
218 },
219 _ => Step {
220 value: DEFAULT_STEP,
221 warnings: vec![APPROXIMATED_WARNING.to_string()],
222 },
223 }
224}
225
226pub fn ax_delta(steps: i32, step: &Step) -> f64 {
229 f64::from(steps) * step.value
230}
231
232pub fn next_value(before: f64, steps: i32, step: &Step) -> f64 {
234 (before + ax_delta(steps, step)).clamp(0.0, 1.0)
235}
236
237pub fn decide(before: Option<f64>, after: Option<f64>, steps: i32, step: &Step) -> Decision {
246 let (Some(before), Some(after)) = (before, after) else {
247 return Decision::Unverifiable;
248 };
249 if !before.is_finite() || !after.is_finite() {
250 return Decision::Unverifiable;
251 }
252 if moved(before, after) {
253 return Decision::Event { before, after };
254 }
255 if at_limit(before, steps) {
256 return Decision::AtLimit { value: before };
257 }
258 Decision::AxScrollbar {
259 before,
260 next: next_value(before, steps, step),
261 }
262}
263
264#[derive(Debug, Clone, PartialEq)]
266pub struct Outcome {
267 pub via: Via,
269 pub scrolled: bool,
271 pub before: Option<f64>,
273 pub after: Option<f64>,
275 pub step: Option<f64>,
279 pub warnings: Vec<String>,
281}
282
283impl Outcome {
284 pub fn event(before: f64, after: f64, step: f64) -> Self {
286 Self {
287 via: Via::Event,
288 scrolled: moved(before, after),
289 before: Some(before),
290 after: Some(after),
291 step: Some(step),
292 warnings: Vec::new(),
293 }
294 }
295
296 pub fn ax_scrollbar(
298 before: f64,
299 read_back: Option<f64>,
300 step: f64,
301 warnings: Vec<String>,
302 ) -> Self {
303 let scrolled = read_back.is_some_and(|value| moved(before, value));
304 Self {
305 via: Via::AxScrollbar,
306 scrolled,
307 before: Some(before),
308 after: read_back,
309 step: Some(step),
310 warnings,
311 }
312 }
313
314 pub fn at_limit(axis: Axis, steps: i32, value: f64, step: Option<f64>) -> Self {
316 Self {
317 via: Via::Event,
318 scrolled: false,
319 before: Some(value),
320 after: Some(value),
321 step,
322 warnings: vec![format!(
323 "{} scroll bar is already at the {} limit",
324 axis.as_str(),
325 axis.limit(steps)
326 )],
327 }
328 }
329
330 pub fn none() -> Self {
336 Self {
337 via: Via::Event,
338 scrolled: false,
339 before: None,
340 after: None,
341 step: None,
342 warnings: Vec::new(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum AxFailure {
353 Refused,
355 Unconfirmed,
357}
358
359impl AxFailure {
360 pub fn reason(self, axis: Axis) -> String {
362 match self {
363 Self::Refused => format!("AX refused the {} scroll bar value write", axis.as_str()),
364 Self::Unconfirmed => format!(
365 "the {} scroll bar value did not change after AX wrote it",
366 axis.as_str()
367 ),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum AxCause {
379 WheelEventNotDelivered,
381 ExplicitAxTier,
383}
384
385impl AxCause {
386 pub fn warning(self) -> Option<&'static str> {
388 match self {
389 Self::WheelEventNotDelivered => Some(AX_FALLBACK_WARNING),
390 Self::ExplicitAxTier => None,
391 }
392 }
393}
394
395pub fn ax_result(
405 axis: Axis,
406 before: f64,
407 requested: f64,
408 read_back: Option<f64>,
409 cause: AxCause,
410 step: &Step,
411) -> Result<Outcome, AxFailure> {
412 let scrolled = read_back.is_some_and(|value| moved(before, value));
413 if !scrolled {
414 return Err(AxFailure::Unconfirmed);
415 }
416 let mut warnings = step.warnings.clone();
417 if let Some(text) = cause.warning() {
418 warnings.push(text.to_string());
419 }
420 if let Some(value) = read_back {
421 if (value - requested).abs() > ROUNDING_TOLERANCE {
422 warnings.push(format!(
423 "{} scroll bar landed on {value:.3} instead of the requested {requested:.3}",
424 axis.as_str()
425 ));
426 }
427 }
428 Ok(Outcome::ax_scrollbar(
429 before, read_back, step.value, warnings,
430 ))
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn wheel_event_that_moved_the_bar_wins_over_everything() {
439 let step = Step::measured(0.2);
440 assert_eq!(
441 decide(Some(0.0), Some(0.2), 3, &step),
442 Decision::Event {
443 before: 0.0,
444 after: 0.2
445 }
446 );
447 assert_eq!(
449 decide(Some(0.9), Some(0.7), -3, &step),
450 Decision::Event {
451 before: 0.9,
452 after: 0.7
453 }
454 );
455 }
456
457 #[test]
458 fn an_unmoved_bar_outside_its_limit_asks_for_the_ax_write() {
459 let step = Step::measured(0.1);
460 let Decision::AxScrollbar { before, next } = decide(Some(0.0), Some(0.0), 3, &step) else {
461 panic!("expected the AX scroll bar path");
462 };
463 assert_eq!(before, 0.0);
464 assert!((next - 0.3).abs() < 1e-9, "next was {next}");
466 }
467
468 #[test]
469 fn an_unmoved_bar_at_its_limit_is_not_a_fallback() {
470 let step = Step::measured(0.2);
471 assert_eq!(
472 decide(Some(1.0), Some(1.0), 3, &step),
473 Decision::AtLimit { value: 1.0 }
474 );
475 assert_eq!(
476 decide(Some(0.0), Some(0.0), -3, &step),
477 Decision::AtLimit { value: 0.0 }
478 );
479 assert!(matches!(
481 decide(Some(1.0), Some(1.0), -3, &step),
482 Decision::AxScrollbar { .. }
483 ));
484 assert!(matches!(
486 decide(Some(0.0), Some(0.0), 3, &step),
487 Decision::AxScrollbar { .. }
488 ));
489 }
490
491 #[test]
492 fn a_missing_or_unreadable_bar_is_unverifiable() {
493 let step = Step::approx();
494 assert_eq!(decide(None, Some(0.2), 3, &step), Decision::Unverifiable);
495 assert_eq!(decide(Some(0.0), None, 3, &step), Decision::Unverifiable);
496 assert_eq!(decide(None, None, 3, &step), Decision::Unverifiable);
497 assert_eq!(
498 decide(Some(f64::NAN), Some(0.2), 3, &step),
499 Decision::Unverifiable
500 );
501 assert_eq!(
502 decide(Some(f64::INFINITY), Some(0.2), 3, &step),
503 Decision::Unverifiable
504 );
505 }
506
507 #[test]
508 fn a_rounding_wobble_is_not_a_scroll() {
509 assert!(!moved(0.2, 0.2 + EPSILON / 2.0));
510 assert!(moved(0.2, 0.2 + EPSILON * 10.0));
511 }
512
513 #[test]
514 fn content_step_uses_line_points_over_the_content_overflow() {
515 let step = content_step(Some(2400.0), Some(400.0));
517 assert!((step.value - 0.02).abs() < 1e-9, "step was {}", step.value);
518 assert!(step.warnings.is_empty(), "{:?}", step.warnings);
519
520 let step = content_step(Some(1160.0), Some(1000.0));
522 assert!((step.value - 0.25).abs() < 1e-9, "step was {}", step.value);
524 }
525
526 #[test]
527 fn content_step_clamps_into_zero_to_one() {
528 let step = content_step(Some(1040.0), Some(1000.0));
531 assert!((step.value - 1.0).abs() < 1e-9, "step was {}", step.value);
532 assert!(step.warnings.is_empty());
533 assert!(step.value >= 0.0 && step.value <= 1.0);
534
535 let step = content_step(Some(10_001.0), Some(10_000.0));
537 assert!(step.value > 0.0, "step was {}", step.value);
538 assert!(step.value <= 1.0);
539 }
540
541 #[test]
542 fn content_step_says_content_fits_when_there_is_nothing_to_scroll() {
543 let step = content_step(Some(800.0), Some(800.0));
545 assert_eq!(step.value, 0.0);
546 assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
547
548 let step = content_step(Some(400.0), Some(800.0));
550 assert_eq!(step.value, 0.0);
551 assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
552 }
553
554 #[test]
555 fn content_step_falls_back_to_a_five_percent_step_when_sizing_is_missing() {
556 let step = content_step(None, None);
558 assert_eq!(step.value, DEFAULT_STEP);
559 assert_eq!(step.warnings, vec![APPROXIMATED_WARNING.to_string()]);
560
561 assert_eq!(
563 content_step(Some(1200.0), None).warnings,
564 vec![APPROXIMATED_WARNING.to_string()]
565 );
566 assert_eq!(
567 content_step(None, Some(800.0)).warnings,
568 vec![APPROXIMATED_WARNING.to_string()]
569 );
570
571 assert_eq!(
573 content_step(Some(f64::NAN), Some(800.0)).value,
574 DEFAULT_STEP
575 );
576 assert_eq!(
577 content_step(Some(1200.0), Some(0.0)).warnings,
578 vec![APPROXIMATED_WARNING.to_string()]
579 );
580 assert_eq!(
581 content_step(Some(1200.0), Some(-10.0)).warnings,
582 vec![APPROXIMATED_WARNING.to_string()]
583 );
584 }
585
586 #[test]
587 fn content_step_drives_next_value_for_ten_wheel_lines() {
588 let step = content_step(Some(2400.0), Some(400.0));
589 assert!((next_value(0.0, 10, &step) - 0.20).abs() < 1e-9);
591 assert!((next_value(0.5, -10, &step) - 0.30).abs() < 1e-9);
592 assert_eq!(next_value(0.95, 10, &step), 1.0);
594 assert_eq!(next_value(0.05, -10, &step), 0.0);
595 }
596
597 #[test]
598 fn step_approx_keeps_the_legacy_default_with_no_warnings() {
599 let step = Step::approx();
600 assert_eq!(step.value, DEFAULT_STEP);
601 assert!(step.warnings.is_empty());
602 }
603
604 #[test]
605 fn ax_delta_multiplies_steps_by_the_step_value() {
606 let step = content_step(Some(2400.0), Some(400.0));
607 assert!((ax_delta(10, &step) - 0.20).abs() < 1e-9);
608 assert!((ax_delta(-10, &step) + 0.20).abs() < 1e-9);
609 assert_eq!(ax_delta(0, &step), 0.0);
610
611 let step = Step::approx();
613 assert!((ax_delta(3, &step) - 0.15).abs() < 1e-9);
614 }
615
616 #[test]
617 fn the_written_value_is_clamped_into_zero_to_one() {
618 let step = Step::approx();
619 assert_eq!(next_value(0.0, -5, &step), 0.0);
620 assert_eq!(next_value(1.0, 5, &step), 1.0);
621 assert!((next_value(0.5, 2, &step) - 0.6).abs() < 1e-9);
622 assert!((next_value(0.5, -2, &step) - 0.4).abs() < 1e-9);
623 }
624
625 #[test]
626 fn at_limit_only_answers_for_a_nonzero_step_in_that_direction() {
627 assert!(at_limit(1.0, 1));
628 assert!(at_limit(1.0 - EPSILON / 2.0, 1));
629 assert!(!at_limit(1.0, -1));
630 assert!(at_limit(0.0, -1));
631 assert!(at_limit(EPSILON / 2.0, -1));
633 assert!(!at_limit(0.999_9, 1));
634 assert!(!at_limit(0.000_1, -1));
635 assert!(!at_limit(1.0, 0));
637 assert!(!at_limit(0.0, 0));
638 }
639
640 #[test]
641 fn an_event_outcome_reports_a_move_only_when_the_value_changed() {
642 let moved_outcome = Outcome::event(0.0, 0.2, 0.05);
643 assert_eq!(moved_outcome.via, Via::Event);
644 assert!(moved_outcome.scrolled);
645 assert_eq!(moved_outcome.before, Some(0.0));
646 assert_eq!(moved_outcome.after, Some(0.2));
647 assert_eq!(moved_outcome.step, Some(0.05));
648 assert!(moved_outcome.warnings.is_empty());
649 assert!(!Outcome::event(0.5, 0.5, 0.05).scrolled);
650 assert_eq!(Outcome::event(0.5, 0.5, 0.05).step, Some(0.05));
651 }
652
653 #[test]
654 fn an_ax_outcome_carries_the_fallback_warning_and_step() {
655 let outcome =
656 Outcome::ax_scrollbar(0.0, Some(0.3), 0.1, vec![AX_FALLBACK_WARNING.to_string()]);
657 assert_eq!(outcome.via, Via::AxScrollbar);
658 assert!(outcome.scrolled);
659 assert_eq!(outcome.after, Some(0.3));
660 assert_eq!(outcome.step, Some(0.1));
661 assert_eq!(outcome.warnings, vec![AX_FALLBACK_WARNING.to_string()]);
662 assert_eq!(Via::AxScrollbar.as_str(), "ax_scrollbar");
663 assert_eq!(Via::Event.as_str(), "event");
664 }
665
666 #[test]
667 fn at_limit_says_so_without_claiming_a_scroll() {
668 let outcome = Outcome::at_limit(Axis::Vertical, 3, 1.0, Some(0.05));
669 assert!(!outcome.scrolled);
670 assert_eq!(outcome.before, Some(1.0));
671 assert_eq!(outcome.after, Some(1.0));
672 assert_eq!(outcome.step, Some(0.05));
673 assert_eq!(outcome.warnings.len(), 1);
674 assert!(
675 outcome.warnings[0].contains("bottom"),
676 "{:?}",
677 outcome.warnings
678 );
679 assert!(
680 Outcome::at_limit(Axis::Horizontal, -2, 0.0, Some(0.05)).warnings[0].contains("left")
681 );
682 assert_eq!(Axis::Vertical.bar_attribute(), "AXVerticalScrollBar");
683 assert_eq!(Axis::Horizontal.bar_attribute(), "AXHorizontalScrollBar");
684 }
685
686 #[test]
687 fn an_unconfirmed_ax_write_is_a_failure_not_a_scroll() {
688 let step = Step::approx();
689 assert_eq!(
690 ax_result(
691 Axis::Vertical,
692 0.0,
693 0.3,
694 Some(0.0),
695 AxCause::WheelEventNotDelivered,
696 &step,
697 ),
698 Err(AxFailure::Unconfirmed)
699 );
700 assert_eq!(
701 ax_result(
702 Axis::Vertical,
703 0.0,
704 0.3,
705 None,
706 AxCause::WheelEventNotDelivered,
707 &step,
708 ),
709 Err(AxFailure::Unconfirmed)
710 );
711 assert_eq!(
712 AxFailure::Refused.reason(Axis::Horizontal),
713 "AX refused the horizontal scroll bar value write"
714 );
715 assert!(AxFailure::Unconfirmed
716 .reason(Axis::Vertical)
717 .contains("did not change"));
718 }
719
720 #[test]
721 fn an_app_rounding_to_its_own_page_still_counts_as_scrolled() {
722 let step = Step::measured(0.1);
723 let outcome = ax_result(
724 Axis::Vertical,
725 0.0,
726 0.3,
727 Some(0.25),
728 AxCause::WheelEventNotDelivered,
729 &step,
730 )
731 .expect("scrolled");
732 assert!(outcome.scrolled);
733 assert_eq!(outcome.via, Via::AxScrollbar);
734 assert_eq!(outcome.after, Some(0.25));
735 assert_eq!(outcome.step, Some(0.1));
736 assert_eq!(outcome.warnings[0], AX_FALLBACK_WARNING);
737 assert!(
738 outcome.warnings[1].contains("0.250") && outcome.warnings[1].contains("0.300"),
739 "{:?}",
740 outcome.warnings
741 );
742 }
743
744 #[test]
745 fn ax_result_merges_step_warnings_alongside_the_fallback_note() {
746 let step = content_step(None, None);
750 let outcome = ax_result(
751 Axis::Vertical,
752 0.0,
753 0.15,
754 Some(0.15),
755 AxCause::WheelEventNotDelivered,
756 &step,
757 )
758 .expect("scrolled");
759 assert_eq!(outcome.step, Some(DEFAULT_STEP));
760 assert_eq!(
761 outcome.warnings,
762 vec![
763 APPROXIMATED_WARNING.to_string(),
764 AX_FALLBACK_WARNING.to_string()
765 ]
766 );
767
768 let step = content_step(Some(800.0), Some(800.0));
772 let outcome = ax_result(
773 Axis::Vertical,
774 0.0,
775 0.0,
776 Some(0.0),
777 AxCause::ExplicitAxTier,
778 &step,
779 );
780 assert_eq!(outcome, Err(AxFailure::Unconfirmed));
781
782 let step = content_step(Some(800.0), Some(800.0));
785 let outcome = ax_result(
786 Axis::Vertical,
787 0.0,
788 0.0,
789 Some(0.001),
790 AxCause::ExplicitAxTier,
791 &step,
792 )
793 .expect("scrolled");
794 assert_eq!(outcome.step, Some(0.0));
795 assert_eq!(outcome.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
796 }
797
798 #[test]
799 fn axes_pick_their_own_delta_and_direction() {
800 assert_eq!(Axis::Vertical.delta(3, 7), 7);
801 assert_eq!(Axis::Horizontal.delta(3, 7), 3);
802 assert_eq!(Axis::Vertical.limit(1), "bottom");
803 assert_eq!(Axis::Vertical.limit(-1), "top");
804 assert_eq!(Axis::Horizontal.limit(0), "right");
805 assert_eq!(Axis::both(), [Axis::Vertical, Axis::Horizontal]);
806 assert_eq!(Axis::Vertical.as_str(), "vertical");
807 }
808
809 #[test]
810 fn an_explicit_ax_scroll_never_claims_a_wheel_event_was_dropped() {
811 let step = Step::measured(0.1);
814 let outcome = ax_result(
815 Axis::Vertical,
816 0.0,
817 0.3,
818 Some(0.3),
819 AxCause::ExplicitAxTier,
820 &step,
821 )
822 .expect("scrolled");
823 assert!(outcome.scrolled);
824 assert_eq!(outcome.via, Via::AxScrollbar);
825 assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
826 assert_eq!(AxCause::ExplicitAxTier.warning(), None);
827 assert_eq!(
828 AxCause::WheelEventNotDelivered.warning(),
829 Some(AX_FALLBACK_WARNING)
830 );
831 assert_eq!(
833 ax_result(
834 Axis::Vertical,
835 0.0,
836 0.3,
837 Some(0.0),
838 AxCause::ExplicitAxTier,
839 &step,
840 ),
841 Err(AxFailure::Unconfirmed)
842 );
843 }
844
845 #[test]
846 fn a_zero_delta_scroll_is_not_a_scroll_and_not_a_failure() {
847 let outcome = Outcome::none();
848 assert!(!outcome.scrolled);
849 assert_eq!(outcome.before, None);
850 assert_eq!(outcome.after, None);
851 assert_eq!(outcome.step, None);
852 assert!(outcome.warnings.is_empty());
853 assert!(!at_limit(0.0, 0));
855 assert!(!at_limit(1.0, 0));
856 let step = Step::measured(0.5);
857 assert_eq!(ax_delta(0, &step), 0.0);
858 assert_eq!(next_value(0.4, 0, &step), 0.4);
859 }
860
861 #[test]
862 fn the_measured_fallback_value_matches_the_decision() {
863 let step = Step::measured(0.3);
864 let Decision::AxScrollbar { before, next } = decide(Some(0.4), Some(0.4), 2, &step) else {
865 panic!("expected the AX scroll bar path");
866 };
867 assert_eq!(next, next_value(before, 2, &step));
868 let outcome = ax_result(
869 Axis::Vertical,
870 before,
871 next,
872 Some(next),
873 AxCause::WheelEventNotDelivered,
874 &step,
875 )
876 .expect("scrolled");
877 assert!(outcome.scrolled);
878 assert_eq!(outcome.after, Some(next));
879 assert_eq!(outcome.step, Some(0.3));
880 }
881}