brightwheel 0.1.3

Windows tray utility for DDC/CI monitor brightness and HDR control
use std::time::{Duration, Instant};

use windows_sys::Win32::UI::WindowsAndMessaging::{
    WHEEL_DELTA, WM_LBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL,
};

const ACCELERATION_RESET: Duration = Duration::from_millis(350);

#[derive(Clone, Copy)]
pub(crate) struct WheelEvent {
    pub(crate) steps: i32,
    pub(crate) timestamp: Instant,
    pub(crate) generation: u32,
}

#[derive(Default)]
pub(crate) struct WheelAccelerator {
    direction: i32,
    streak: u32,
    last_event: Option<Instant>,
}

impl WheelAccelerator {
    pub(crate) fn reset(&mut self) {
        *self = Self::default();
    }

    pub(crate) fn adjust(&mut self, event: WheelEvent) -> i32 {
        let direction = event.steps.signum();
        let starts_new_streak = direction != self.direction
            || self.last_event.is_none_or(|last| {
                event.timestamp.saturating_duration_since(last) > ACCELERATION_RESET
            });
        if starts_new_streak {
            self.streak = 0;
            self.direction = direction;
        }
        self.last_event = Some(event.timestamp);

        (0..event.steps.unsigned_abs()).fold(0_i32, |adjustment, _| {
            self.streak = self.streak.saturating_add(1);
            adjustment.saturating_add(direction.saturating_mul(step_size(self.streak)))
        })
    }
}

pub(crate) fn steps_from_mouse_data(mouse_data: u32) -> i32 {
    let high_word = (mouse_data >> 16) as u16;
    let delta = i32::from(i16::from_ne_bytes(high_word.to_ne_bytes()));
    let wheel_delta = i32::try_from(WHEEL_DELTA).expect("WHEEL_DELTA fits in i32");
    if delta.abs() >= wheel_delta {
        delta / wheel_delta
    } else {
        delta.signum()
    }
}

pub(crate) fn is_double_click(previous: u32, current: u32, maximum_interval: u32) -> bool {
    previous != 0 && current.wrapping_sub(previous) <= maximum_interval
}

pub(crate) fn needs_tray_hit_test(
    message: u32,
    interaction_active: bool,
    click_pending: bool,
) -> bool {
    match message {
        WM_MOUSEMOVE => interaction_active || click_pending,
        WM_MOUSEWHEEL | WM_LBUTTONUP => true,
        _ => false,
    }
}

fn step_size(streak: u32) -> i32 {
    match streak {
        1..=2 => 2,
        3..=5 => 4,
        6..=9 => 7,
        _ => 10,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        WheelAccelerator, WheelEvent, is_double_click, needs_tray_hit_test, step_size,
        steps_from_mouse_data,
    };
    use std::time::{Duration, Instant};
    use windows_sys::Win32::UI::WindowsAndMessaging::{
        WM_LBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONUP,
    };

    fn event(steps: i32, timestamp: Instant) -> WheelEvent {
        WheelEvent {
            steps,
            timestamp,
            generation: 0,
        }
    }

    fn mouse_data(delta: i16) -> u32 {
        u32::from(u16::from_ne_bytes(delta.to_ne_bytes())) << 16
    }

    #[test]
    fn acceleration_curve_has_explicit_boundaries() {
        let values = (1..=11).map(step_size).collect::<Vec<_>>();
        assert_eq!(values, [2, 2, 4, 4, 4, 7, 7, 7, 7, 10, 10]);
    }

    #[test]
    fn accelerates_a_continuous_burst() {
        let start = Instant::now();
        let mut accelerator = WheelAccelerator::default();
        let adjustments = (0..10)
            .map(|index| accelerator.adjust(event(1, start + Duration::from_millis(index * 20))))
            .collect::<Vec<_>>();

        assert_eq!(adjustments, [2, 2, 4, 4, 4, 7, 7, 7, 7, 10]);
    }

    #[test]
    fn applies_each_notch_in_a_multi_step_event() {
        let mut accelerator = WheelAccelerator::default();
        assert_eq!(accelerator.adjust(event(3, Instant::now())), 8);
    }

    #[test]
    fn resets_after_pause_or_direction_change() {
        let start = Instant::now();
        let mut accelerator = WheelAccelerator::default();
        assert_eq!(accelerator.adjust(event(3, start)), 8);
        assert_eq!(
            accelerator.adjust(event(1, start + Duration::from_millis(500))),
            2
        );
        assert_eq!(
            accelerator.adjust(event(-1, start + Duration::from_millis(520))),
            -2
        );
    }

    #[test]
    fn converts_standard_and_high_resolution_wheel_deltas() {
        assert_eq!(steps_from_mouse_data(mouse_data(120)), 1);
        assert_eq!(steps_from_mouse_data(mouse_data(-120)), -1);
        assert_eq!(steps_from_mouse_data(mouse_data(240)), 2);
        assert_eq!(steps_from_mouse_data(mouse_data(30)), 1);
        assert_eq!(steps_from_mouse_data(mouse_data(-30)), -1);
    }

    #[test]
    fn recognizes_double_clicks_across_timestamp_wraparound() {
        assert!(!is_double_click(0, 100, 500));
        assert!(is_double_click(100, 450, 500));
        assert!(!is_double_click(100, 601, 500));
        assert!(is_double_click(u32::MAX - 10, 5, 20));
    }

    #[test]
    fn avoids_shell_hit_tests_for_unrelated_and_idle_mouse_events() {
        assert!(!needs_tray_hit_test(WM_MOUSEMOVE, false, false));
        assert!(needs_tray_hit_test(WM_MOUSEMOVE, true, false));
        assert!(needs_tray_hit_test(WM_MOUSEMOVE, false, true));
        assert!(needs_tray_hit_test(WM_MOUSEWHEEL, false, false));
        assert!(needs_tray_hit_test(WM_LBUTTONUP, false, false));
        assert!(!needs_tray_hit_test(WM_RBUTTONUP, true, true));
    }
}