pub const EPSILON: f64 = 1e-6;
pub const AX_FALLBACK_WARNING: &str =
"wheel event not delivered to background app; used AX scroll bar";
pub const APPROXIMATED_WARNING: &str = "scroll step approximated";
pub const CONTENT_FITS_WARNING: &str = "content fits; nothing to scroll";
pub const DEFAULT_STEP: f64 = 0.05;
pub const LINE_POINTS: f64 = 40.0;
pub const MIN_STEP: f64 = 0.005;
pub const MAX_STEP: f64 = 1.0;
pub const ROUNDING_TOLERANCE: f64 = 0.005;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Axis {
Vertical,
Horizontal,
}
impl Axis {
pub fn as_str(self) -> &'static str {
match self {
Self::Vertical => "vertical",
Self::Horizontal => "horizontal",
}
}
pub fn limit(self, steps: i32) -> &'static str {
match (self, steps >= 0) {
(Self::Vertical, true) => "bottom",
(Self::Vertical, false) => "top",
(Self::Horizontal, true) => "right",
(Self::Horizontal, false) => "left",
}
}
pub fn bar_attribute(self) -> &'static str {
match self {
Self::Vertical => "AXVerticalScrollBar",
Self::Horizontal => "AXHorizontalScrollBar",
}
}
pub fn delta(self, dx: i32, dy: i32) -> i32 {
match self {
Self::Vertical => dy,
Self::Horizontal => dx,
}
}
pub fn both() -> [Self; 2] {
[Self::Vertical, Self::Horizontal]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Via {
Event,
AxScrollbar,
}
impl Via {
pub fn as_str(self) -> &'static str {
match self {
Self::Event => "event",
Self::AxScrollbar => "ax_scrollbar",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Decision {
Event { before: f64, after: f64 },
AxScrollbar { before: f64, next: f64 },
AtLimit { value: f64 },
Unverifiable,
}
pub fn moved(before: f64, after: f64) -> bool {
(after - before).abs() > EPSILON
}
pub fn at_limit(value: f64, steps: i32) -> bool {
match steps.cmp(&0) {
std::cmp::Ordering::Greater => value >= 1.0 - EPSILON,
std::cmp::Ordering::Less => value <= EPSILON,
std::cmp::Ordering::Equal => false,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Step {
pub value: f64,
pub warnings: Vec<String>,
}
impl Step {
pub const fn measured(value: f64) -> Self {
Self {
value,
warnings: Vec::new(),
}
}
pub const fn approx() -> Self {
Self::measured(DEFAULT_STEP)
}
}
pub fn content_step(content: Option<f64>, visible: Option<f64>) -> Step {
match (content, visible) {
(Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 && c > v => {
Step::measured((LINE_POINTS / (c - v)).clamp(0.0, 1.0))
}
(Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 => Step {
value: 0.0,
warnings: vec![CONTENT_FITS_WARNING.to_string()],
},
_ => Step {
value: DEFAULT_STEP,
warnings: vec![APPROXIMATED_WARNING.to_string()],
},
}
}
pub fn ax_delta(steps: i32, step: &Step) -> f64 {
f64::from(steps) * step.value
}
pub fn next_value(before: f64, steps: i32, step: &Step) -> f64 {
(before + ax_delta(steps, step)).clamp(0.0, 1.0)
}
pub fn decide(before: Option<f64>, after: Option<f64>, steps: i32, step: &Step) -> Decision {
let (Some(before), Some(after)) = (before, after) else {
return Decision::Unverifiable;
};
if !before.is_finite() || !after.is_finite() {
return Decision::Unverifiable;
}
if moved(before, after) {
return Decision::Event { before, after };
}
if at_limit(before, steps) {
return Decision::AtLimit { value: before };
}
Decision::AxScrollbar {
before,
next: next_value(before, steps, step),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Outcome {
pub via: Via,
pub scrolled: bool,
pub before: Option<f64>,
pub after: Option<f64>,
pub step: Option<f64>,
pub warnings: Vec<String>,
}
impl Outcome {
pub fn event(before: f64, after: f64, step: f64) -> Self {
Self {
via: Via::Event,
scrolled: moved(before, after),
before: Some(before),
after: Some(after),
step: Some(step),
warnings: Vec::new(),
}
}
pub fn ax_scrollbar(
before: f64,
read_back: Option<f64>,
step: f64,
warnings: Vec<String>,
) -> Self {
let scrolled = read_back.is_some_and(|value| moved(before, value));
Self {
via: Via::AxScrollbar,
scrolled,
before: Some(before),
after: read_back,
step: Some(step),
warnings,
}
}
pub fn at_limit(axis: Axis, steps: i32, value: f64, step: Option<f64>) -> Self {
Self {
via: Via::Event,
scrolled: false,
before: Some(value),
after: Some(value),
step,
warnings: vec![format!(
"{} scroll bar is already at the {} limit",
axis.as_str(),
axis.limit(steps)
)],
}
}
pub fn none() -> Self {
Self {
via: Via::Event,
scrolled: false,
before: None,
after: None,
step: None,
warnings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxFailure {
Refused,
Unconfirmed,
}
impl AxFailure {
pub fn reason(self, axis: Axis) -> String {
match self {
Self::Refused => format!("AX refused the {} scroll bar value write", axis.as_str()),
Self::Unconfirmed => format!(
"the {} scroll bar value did not change after AX wrote it",
axis.as_str()
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxCause {
WheelEventNotDelivered,
ExplicitAxTier,
}
impl AxCause {
pub fn warning(self) -> Option<&'static str> {
match self {
Self::WheelEventNotDelivered => Some(AX_FALLBACK_WARNING),
Self::ExplicitAxTier => None,
}
}
}
pub fn ax_result(
axis: Axis,
before: f64,
requested: f64,
read_back: Option<f64>,
cause: AxCause,
step: &Step,
) -> Result<Outcome, AxFailure> {
let scrolled = read_back.is_some_and(|value| moved(before, value));
if !scrolled {
return Err(AxFailure::Unconfirmed);
}
let mut warnings = step.warnings.clone();
if let Some(text) = cause.warning() {
warnings.push(text.to_string());
}
if let Some(value) = read_back {
if (value - requested).abs() > ROUNDING_TOLERANCE {
warnings.push(format!(
"{} scroll bar landed on {value:.3} instead of the requested {requested:.3}",
axis.as_str()
));
}
}
Ok(Outcome::ax_scrollbar(
before, read_back, step.value, warnings,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_event_that_moved_the_bar_wins_over_everything() {
let step = Step::measured(0.2);
assert_eq!(
decide(Some(0.0), Some(0.2), 3, &step),
Decision::Event {
before: 0.0,
after: 0.2
}
);
assert_eq!(
decide(Some(0.9), Some(0.7), -3, &step),
Decision::Event {
before: 0.9,
after: 0.7
}
);
}
#[test]
fn an_unmoved_bar_outside_its_limit_asks_for_the_ax_write() {
let step = Step::measured(0.1);
let Decision::AxScrollbar { before, next } = decide(Some(0.0), Some(0.0), 3, &step) else {
panic!("expected the AX scroll bar path");
};
assert_eq!(before, 0.0);
assert!((next - 0.3).abs() < 1e-9, "next was {next}");
}
#[test]
fn an_unmoved_bar_at_its_limit_is_not_a_fallback() {
let step = Step::measured(0.2);
assert_eq!(
decide(Some(1.0), Some(1.0), 3, &step),
Decision::AtLimit { value: 1.0 }
);
assert_eq!(
decide(Some(0.0), Some(0.0), -3, &step),
Decision::AtLimit { value: 0.0 }
);
assert!(matches!(
decide(Some(1.0), Some(1.0), -3, &step),
Decision::AxScrollbar { .. }
));
assert!(matches!(
decide(Some(0.0), Some(0.0), 3, &step),
Decision::AxScrollbar { .. }
));
}
#[test]
fn a_missing_or_unreadable_bar_is_unverifiable() {
let step = Step::approx();
assert_eq!(decide(None, Some(0.2), 3, &step), Decision::Unverifiable);
assert_eq!(decide(Some(0.0), None, 3, &step), Decision::Unverifiable);
assert_eq!(decide(None, None, 3, &step), Decision::Unverifiable);
assert_eq!(
decide(Some(f64::NAN), Some(0.2), 3, &step),
Decision::Unverifiable
);
assert_eq!(
decide(Some(f64::INFINITY), Some(0.2), 3, &step),
Decision::Unverifiable
);
}
#[test]
fn a_rounding_wobble_is_not_a_scroll() {
assert!(!moved(0.2, 0.2 + EPSILON / 2.0));
assert!(moved(0.2, 0.2 + EPSILON * 10.0));
}
#[test]
fn content_step_uses_line_points_over_the_content_overflow() {
let step = content_step(Some(2400.0), Some(400.0));
assert!((step.value - 0.02).abs() < 1e-9, "step was {}", step.value);
assert!(step.warnings.is_empty(), "{:?}", step.warnings);
let step = content_step(Some(1160.0), Some(1000.0));
assert!((step.value - 0.25).abs() < 1e-9, "step was {}", step.value);
}
#[test]
fn content_step_clamps_into_zero_to_one() {
let step = content_step(Some(1040.0), Some(1000.0));
assert!((step.value - 1.0).abs() < 1e-9, "step was {}", step.value);
assert!(step.warnings.is_empty());
assert!(step.value >= 0.0 && step.value <= 1.0);
let step = content_step(Some(10_001.0), Some(10_000.0));
assert!(step.value > 0.0, "step was {}", step.value);
assert!(step.value <= 1.0);
}
#[test]
fn content_step_says_content_fits_when_there_is_nothing_to_scroll() {
let step = content_step(Some(800.0), Some(800.0));
assert_eq!(step.value, 0.0);
assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
let step = content_step(Some(400.0), Some(800.0));
assert_eq!(step.value, 0.0);
assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
}
#[test]
fn content_step_falls_back_to_a_five_percent_step_when_sizing_is_missing() {
let step = content_step(None, None);
assert_eq!(step.value, DEFAULT_STEP);
assert_eq!(step.warnings, vec![APPROXIMATED_WARNING.to_string()]);
assert_eq!(
content_step(Some(1200.0), None).warnings,
vec![APPROXIMATED_WARNING.to_string()]
);
assert_eq!(
content_step(None, Some(800.0)).warnings,
vec![APPROXIMATED_WARNING.to_string()]
);
assert_eq!(
content_step(Some(f64::NAN), Some(800.0)).value,
DEFAULT_STEP
);
assert_eq!(
content_step(Some(1200.0), Some(0.0)).warnings,
vec![APPROXIMATED_WARNING.to_string()]
);
assert_eq!(
content_step(Some(1200.0), Some(-10.0)).warnings,
vec![APPROXIMATED_WARNING.to_string()]
);
}
#[test]
fn content_step_drives_next_value_for_ten_wheel_lines() {
let step = content_step(Some(2400.0), Some(400.0));
assert!((next_value(0.0, 10, &step) - 0.20).abs() < 1e-9);
assert!((next_value(0.5, -10, &step) - 0.30).abs() < 1e-9);
assert_eq!(next_value(0.95, 10, &step), 1.0);
assert_eq!(next_value(0.05, -10, &step), 0.0);
}
#[test]
fn step_approx_keeps_the_legacy_default_with_no_warnings() {
let step = Step::approx();
assert_eq!(step.value, DEFAULT_STEP);
assert!(step.warnings.is_empty());
}
#[test]
fn ax_delta_multiplies_steps_by_the_step_value() {
let step = content_step(Some(2400.0), Some(400.0));
assert!((ax_delta(10, &step) - 0.20).abs() < 1e-9);
assert!((ax_delta(-10, &step) + 0.20).abs() < 1e-9);
assert_eq!(ax_delta(0, &step), 0.0);
let step = Step::approx();
assert!((ax_delta(3, &step) - 0.15).abs() < 1e-9);
}
#[test]
fn the_written_value_is_clamped_into_zero_to_one() {
let step = Step::approx();
assert_eq!(next_value(0.0, -5, &step), 0.0);
assert_eq!(next_value(1.0, 5, &step), 1.0);
assert!((next_value(0.5, 2, &step) - 0.6).abs() < 1e-9);
assert!((next_value(0.5, -2, &step) - 0.4).abs() < 1e-9);
}
#[test]
fn at_limit_only_answers_for_a_nonzero_step_in_that_direction() {
assert!(at_limit(1.0, 1));
assert!(at_limit(1.0 - EPSILON / 2.0, 1));
assert!(!at_limit(1.0, -1));
assert!(at_limit(0.0, -1));
assert!(at_limit(EPSILON / 2.0, -1));
assert!(!at_limit(0.999_9, 1));
assert!(!at_limit(0.000_1, -1));
assert!(!at_limit(1.0, 0));
assert!(!at_limit(0.0, 0));
}
#[test]
fn an_event_outcome_reports_a_move_only_when_the_value_changed() {
let moved_outcome = Outcome::event(0.0, 0.2, 0.05);
assert_eq!(moved_outcome.via, Via::Event);
assert!(moved_outcome.scrolled);
assert_eq!(moved_outcome.before, Some(0.0));
assert_eq!(moved_outcome.after, Some(0.2));
assert_eq!(moved_outcome.step, Some(0.05));
assert!(moved_outcome.warnings.is_empty());
assert!(!Outcome::event(0.5, 0.5, 0.05).scrolled);
assert_eq!(Outcome::event(0.5, 0.5, 0.05).step, Some(0.05));
}
#[test]
fn an_ax_outcome_carries_the_fallback_warning_and_step() {
let outcome =
Outcome::ax_scrollbar(0.0, Some(0.3), 0.1, vec![AX_FALLBACK_WARNING.to_string()]);
assert_eq!(outcome.via, Via::AxScrollbar);
assert!(outcome.scrolled);
assert_eq!(outcome.after, Some(0.3));
assert_eq!(outcome.step, Some(0.1));
assert_eq!(outcome.warnings, vec![AX_FALLBACK_WARNING.to_string()]);
assert_eq!(Via::AxScrollbar.as_str(), "ax_scrollbar");
assert_eq!(Via::Event.as_str(), "event");
}
#[test]
fn at_limit_says_so_without_claiming_a_scroll() {
let outcome = Outcome::at_limit(Axis::Vertical, 3, 1.0, Some(0.05));
assert!(!outcome.scrolled);
assert_eq!(outcome.before, Some(1.0));
assert_eq!(outcome.after, Some(1.0));
assert_eq!(outcome.step, Some(0.05));
assert_eq!(outcome.warnings.len(), 1);
assert!(
outcome.warnings[0].contains("bottom"),
"{:?}",
outcome.warnings
);
assert!(
Outcome::at_limit(Axis::Horizontal, -2, 0.0, Some(0.05)).warnings[0].contains("left")
);
assert_eq!(Axis::Vertical.bar_attribute(), "AXVerticalScrollBar");
assert_eq!(Axis::Horizontal.bar_attribute(), "AXHorizontalScrollBar");
}
#[test]
fn an_unconfirmed_ax_write_is_a_failure_not_a_scroll() {
let step = Step::approx();
assert_eq!(
ax_result(
Axis::Vertical,
0.0,
0.3,
Some(0.0),
AxCause::WheelEventNotDelivered,
&step,
),
Err(AxFailure::Unconfirmed)
);
assert_eq!(
ax_result(
Axis::Vertical,
0.0,
0.3,
None,
AxCause::WheelEventNotDelivered,
&step,
),
Err(AxFailure::Unconfirmed)
);
assert_eq!(
AxFailure::Refused.reason(Axis::Horizontal),
"AX refused the horizontal scroll bar value write"
);
assert!(AxFailure::Unconfirmed
.reason(Axis::Vertical)
.contains("did not change"));
}
#[test]
fn an_app_rounding_to_its_own_page_still_counts_as_scrolled() {
let step = Step::measured(0.1);
let outcome = ax_result(
Axis::Vertical,
0.0,
0.3,
Some(0.25),
AxCause::WheelEventNotDelivered,
&step,
)
.expect("scrolled");
assert!(outcome.scrolled);
assert_eq!(outcome.via, Via::AxScrollbar);
assert_eq!(outcome.after, Some(0.25));
assert_eq!(outcome.step, Some(0.1));
assert_eq!(outcome.warnings[0], AX_FALLBACK_WARNING);
assert!(
outcome.warnings[1].contains("0.250") && outcome.warnings[1].contains("0.300"),
"{:?}",
outcome.warnings
);
}
#[test]
fn ax_result_merges_step_warnings_alongside_the_fallback_note() {
let step = content_step(None, None);
let outcome = ax_result(
Axis::Vertical,
0.0,
0.15,
Some(0.15),
AxCause::WheelEventNotDelivered,
&step,
)
.expect("scrolled");
assert_eq!(outcome.step, Some(DEFAULT_STEP));
assert_eq!(
outcome.warnings,
vec![
APPROXIMATED_WARNING.to_string(),
AX_FALLBACK_WARNING.to_string()
]
);
let step = content_step(Some(800.0), Some(800.0));
let outcome = ax_result(
Axis::Vertical,
0.0,
0.0,
Some(0.0),
AxCause::ExplicitAxTier,
&step,
);
assert_eq!(outcome, Err(AxFailure::Unconfirmed));
let step = content_step(Some(800.0), Some(800.0));
let outcome = ax_result(
Axis::Vertical,
0.0,
0.0,
Some(0.001),
AxCause::ExplicitAxTier,
&step,
)
.expect("scrolled");
assert_eq!(outcome.step, Some(0.0));
assert_eq!(outcome.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
}
#[test]
fn axes_pick_their_own_delta_and_direction() {
assert_eq!(Axis::Vertical.delta(3, 7), 7);
assert_eq!(Axis::Horizontal.delta(3, 7), 3);
assert_eq!(Axis::Vertical.limit(1), "bottom");
assert_eq!(Axis::Vertical.limit(-1), "top");
assert_eq!(Axis::Horizontal.limit(0), "right");
assert_eq!(Axis::both(), [Axis::Vertical, Axis::Horizontal]);
assert_eq!(Axis::Vertical.as_str(), "vertical");
}
#[test]
fn an_explicit_ax_scroll_never_claims_a_wheel_event_was_dropped() {
let step = Step::measured(0.1);
let outcome = ax_result(
Axis::Vertical,
0.0,
0.3,
Some(0.3),
AxCause::ExplicitAxTier,
&step,
)
.expect("scrolled");
assert!(outcome.scrolled);
assert_eq!(outcome.via, Via::AxScrollbar);
assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
assert_eq!(AxCause::ExplicitAxTier.warning(), None);
assert_eq!(
AxCause::WheelEventNotDelivered.warning(),
Some(AX_FALLBACK_WARNING)
);
assert_eq!(
ax_result(
Axis::Vertical,
0.0,
0.3,
Some(0.0),
AxCause::ExplicitAxTier,
&step,
),
Err(AxFailure::Unconfirmed)
);
}
#[test]
fn a_zero_delta_scroll_is_not_a_scroll_and_not_a_failure() {
let outcome = Outcome::none();
assert!(!outcome.scrolled);
assert_eq!(outcome.before, None);
assert_eq!(outcome.after, None);
assert_eq!(outcome.step, None);
assert!(outcome.warnings.is_empty());
assert!(!at_limit(0.0, 0));
assert!(!at_limit(1.0, 0));
let step = Step::measured(0.5);
assert_eq!(ax_delta(0, &step), 0.0);
assert_eq!(next_value(0.4, 0, &step), 0.4);
}
#[test]
fn the_measured_fallback_value_matches_the_decision() {
let step = Step::measured(0.3);
let Decision::AxScrollbar { before, next } = decide(Some(0.4), Some(0.4), 2, &step) else {
panic!("expected the AX scroll bar path");
};
assert_eq!(next, next_value(before, 2, &step));
let outcome = ax_result(
Axis::Vertical,
before,
next,
Some(next),
AxCause::WheelEventNotDelivered,
&step,
)
.expect("scrolled");
assert!(outcome.scrolled);
assert_eq!(outcome.after, Some(next));
assert_eq!(outcome.step, Some(0.3));
}
}