windows-troll 0.1.0

Modular Windows prank library
//! Window wobbler module.
//!
//! Sways a window in place for a short period with configurable direction and
//! amplitude, then restores its original position.

use std::thread;
use std::time::{Duration, Instant};

use rand::{rng, RngExt};
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{
    GetForegroundWindow, GetWindowRect, SetWindowPos, SET_WINDOW_POS_FLAGS, SWP_NOACTIVATE,
    SWP_NOSIZE, SWP_NOZORDER,
};

use crate::desktop::{list_windows, WindowInfo};

/// The axis/axes along which the window sways.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WobbleDirection {
    /// Side to side.
    Horizontal,
    /// Up and down.
    Vertical,
    /// Circular motion (horizontal + vertical combined).
    Both,
}

/// Configuration for a single wobble.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WobbleSettings {
    /// How long the wobble lasts.
    pub duration: Duration,
    /// Which axis/axes to sway along.
    pub direction: WobbleDirection,
    /// Minimum sway amplitude in pixels. The amplitude drifts smoothly between
    /// this and [`max_amplitude`](Self::max_amplitude) during the wobble.
    pub min_amplitude: i32,
    /// Maximum sway amplitude in pixels.
    pub max_amplitude: i32,
}

impl Default for WobbleSettings {
    fn default() -> Self {
        Self {
            duration: Duration::from_secs(2),
            direction: WobbleDirection::Horizontal,
            min_amplitude: 4,
            max_amplitude: 8,
        }
    }
}

const WOBBLE_STEP: Duration = Duration::from_millis(10);
const WOBBLE_FREQ_HZ: f64 = 8.0;
const AMPLITUDE_FREQ_HZ: f64 = 0.5;

fn wobble_flags() -> SET_WINDOW_POS_FLAGS {
    SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
}

/// Sways `window` in place according to `settings`, then restores its original
/// position.
///
/// The motion is a sine oscillation that fades in and out, so the window eases
/// into and out of the wobble. The amplitude drifts smoothly between
/// [`WobbleSettings::min_amplitude`] and
/// [`WobbleSettings::max_amplitude`].
///
/// Returns `false` if the window's position could not be read up front.
pub fn wobble_window(window: HWND, settings: WobbleSettings) -> bool {
    let mut rect = RECT::default();
    if !unsafe { GetWindowRect(window, &mut rect).is_ok() } {
        return false;
    }

    let (base_x, base_y) = (rect.left, rect.top);
    let total = settings.duration.as_secs_f64();
    let min_amp = settings.min_amplitude.max(0) as f64;
    let max_amp = settings
        .max_amplitude
        .max(settings.min_amplitude.max(0)) as f64;
    if total <= 0.0 || max_amp == 0.0 {
        return true;
    }

    let started = Instant::now();
    loop {
        let elapsed = started.elapsed().as_secs_f64();
        if elapsed >= total {
            break;
        }

        // Fade the whole effect in and out so the window eases into the sway.
        let envelope = (std::f64::consts::PI * elapsed / total).sin();
        // Drift the amplitude smoothly between min and max.
        let amp_mod = 0.5 + 0.5 * (2.0 * std::f64::consts::PI * AMPLITUDE_FREQ_HZ * elapsed).sin();
        let amp = (min_amp + (max_amp - min_amp) * amp_mod) * envelope;
        let wobble = (2.0 * std::f64::consts::PI * WOBBLE_FREQ_HZ * elapsed).sin();

        let (dx, dy) = match settings.direction {
            WobbleDirection::Horizontal => (amp * wobble, 0.0),
            WobbleDirection::Vertical => (0.0, amp * wobble),
            WobbleDirection::Both => (
                amp * wobble,
                amp * (wobble + std::f64::consts::FRAC_PI_2).sin(),
            ),
        };

        unsafe {
            let _ = SetWindowPos(
                window,
                None,
                base_x + dx.round() as i32,
                base_y + dy.round() as i32,
                0,
                0,
                wobble_flags(),
            );
        }
        thread::sleep(WOBBLE_STEP);
    }

    unsafe {
        let _ = SetWindowPos(window, None, base_x, base_y, 0, 0, wobble_flags());
    }
    true
}

/// Sways a random eligible window.
///
/// Returns the [`WindowInfo`] of the wobbled window, or `None` if no eligible
/// window was found.
pub fn wobble_random_window(settings: WobbleSettings) -> Option<WindowInfo> {
    let windows = list_windows();
    if windows.is_empty() {
        return None;
    }
    let mut rng = rng();
    let index = rng.random_range(0..windows.len());
    let target = &windows[index];
    wobble_window(target.handle(), settings);
    Some(target.clone())
}

/// Sways the first window whose title matches `title` exactly.
///
/// Returns the [`WindowInfo`] of the wobbled window, or `None` if no window
/// with that title was found.
pub fn wobble_by_title(title: &str, settings: WobbleSettings) -> Option<WindowInfo> {
    list_windows()
        .into_iter()
        .find(|w| w.title() == title)
        .inspect(|w| {
            wobble_window(w.handle(), settings);
        })
}

/// Sways the currently focused window.
///
/// Returns `false` if there is no foreground window.
pub fn wobble_foreground_window(settings: WobbleSettings) -> bool {
    let window = unsafe { GetForegroundWindow() };
    if window.0.is_null() {
        return false;
    }
    wobble_window(window, settings)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn zero_duration_is_a_noop() {
        let window = unsafe { GetForegroundWindow() };
        if window.0.is_null() {
            return;
        }
        let settings = WobbleSettings {
            duration: Duration::ZERO,
            ..WobbleSettings::default()
        };
        assert!(wobble_window(window, settings));
    }
}