use rand::rngs::SmallRng;
use rand::{RngExt, SeedableRng};
use crate::render::motion::MotionProfile;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LifecycleEvent {
pub hide_at_ms: u64,
pub hidden_ms: u64,
}
#[derive(Debug, Clone, Copy)]
pub struct LifecycleParams {
pub mean_gap_ms: u64,
pub hidden_scale_ms: f64,
pub hidden_alpha: f64,
pub hidden_cap_ms: u64,
pub enabled: bool,
}
impl LifecycleParams {
pub fn for_profile(p: MotionProfile) -> Self {
match p {
MotionProfile::Fast => LifecycleParams {
mean_gap_ms: 300_000,
hidden_scale_ms: 0.0,
hidden_alpha: 2.0,
hidden_cap_ms: 0,
enabled: false,
},
MotionProfile::Balanced => LifecycleParams {
mean_gap_ms: 45_000,
hidden_scale_ms: 1_500.0,
hidden_alpha: 1.4,
hidden_cap_ms: 12_000,
enabled: true,
},
MotionProfile::Human => LifecycleParams {
mean_gap_ms: 30_000,
hidden_scale_ms: 2_000.0,
hidden_alpha: 1.3,
hidden_cap_ms: 20_000,
enabled: true,
},
MotionProfile::Paranoid => LifecycleParams {
mean_gap_ms: 20_000,
hidden_scale_ms: 3_000.0,
hidden_alpha: 1.2,
hidden_cap_ms: 30_000,
enabled: true,
},
}
}
}
pub fn schedule(params: &LifecycleParams, n_events: usize, seed: u64) -> Vec<LifecycleEvent> {
if !params.enabled || n_events == 0 {
return Vec::new();
}
let mut rng = SmallRng::seed_from_u64(seed);
let mut out = Vec::with_capacity(n_events);
let mut t: u64 = 0;
for _ in 0..n_events {
let gap = exponential_ms(&mut rng, params.mean_gap_ms as f64) as u64;
t = t.saturating_add(gap);
let hidden = pareto(&mut rng, params.hidden_scale_ms, params.hidden_alpha)
.clamp(params.hidden_scale_ms * 0.3, params.hidden_cap_ms as f64)
as u64;
out.push(LifecycleEvent {
hide_at_ms: t,
hidden_ms: hidden,
});
t = t.saturating_add(hidden);
}
out
}
pub const HIDE_SNIPPET: &str = r#"
(() => {
try {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('blur'));
} catch (_) {}
})();
"#;
pub const SHOW_SNIPPET: &str = r#"
(() => {
try {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('focus'));
} catch (_) {}
})();
"#;
fn u01(rng: &mut SmallRng) -> f64 {
(rng.random::<u32>() as f64) / (u32::MAX as f64 + 1.0)
}
fn exponential_ms(rng: &mut SmallRng, mean: f64) -> f64 {
let u = u01(rng).clamp(1e-6, 1.0 - 1e-6);
-mean * (1.0 - u).ln()
}
fn pareto(rng: &mut SmallRng, scale: f64, alpha: f64) -> f64 {
let u = u01(rng).clamp(1e-6, 1.0 - 1e-6);
scale * (1.0 - u).powf(-1.0 / alpha.max(0.1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fast_profile_disables_events() {
let p = LifecycleParams::for_profile(MotionProfile::Fast);
assert!(!p.enabled);
assert!(schedule(&p, 5, 1).is_empty());
}
#[test]
fn events_are_monotonic() {
let p = LifecycleParams::for_profile(MotionProfile::Balanced);
let ev = schedule(&p, 10, 42);
assert_eq!(ev.len(), 10);
for w in ev.windows(2) {
assert!(
w[1].hide_at_ms >= w[0].hide_at_ms,
"events must be in time order"
);
}
}
#[test]
fn hidden_durations_within_cap() {
let p = LifecycleParams::for_profile(MotionProfile::Balanced);
let ev = schedule(&p, 200, 7);
for e in &ev {
assert!(e.hidden_ms <= p.hidden_cap_ms, "hidden dwell exceeded cap");
assert!(e.hidden_ms > 0);
}
}
#[test]
fn schedule_is_deterministic() {
let p = LifecycleParams::for_profile(MotionProfile::Balanced);
let a = schedule(&p, 8, 123);
let b = schedule(&p, 8, 123);
assert_eq!(a, b);
}
#[test]
fn snippets_are_non_empty() {
assert!(HIDE_SNIPPET.contains("visibilitychange"));
assert!(SHOW_SNIPPET.contains("visibilitychange"));
}
}