use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::preferences::{DisplayPreferences, MotionPreference};
pub const ACCESSIBILITY_SCHEMA_VERSION: u32 = 1;
pub const TARGET_FPS: u32 = 60;
pub const FRAME_BUDGET_MS: u16 = 16;
pub const FRAME_DROP_THRESHOLD_MS: u16 = 33;
pub const INPUT_FEEDBACK_BUDGET_MS: u16 = 100;
const FRAME_TIME_INLINE_HISTOGRAM_BUCKETS: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameTimeBudget {
pub phase: FramePhase,
pub budget_ms: u16,
pub warn_on_exceed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FramePhase {
InputToIntent,
IntentToState,
StateToFrame,
InputToPixels,
}
impl FramePhase {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::InputToIntent => "Input → Intent",
Self::IntentToState => "Intent → State",
Self::StateToFrame => "State → Frame",
Self::InputToPixels => "Input → Pixels",
}
}
}
pub const FRAME_BUDGETS: &[FrameTimeBudget] = &[
FrameTimeBudget {
phase: FramePhase::InputToIntent,
budget_ms: 8,
warn_on_exceed: true,
},
FrameTimeBudget {
phase: FramePhase::IntentToState,
budget_ms: 12,
warn_on_exceed: true,
},
FrameTimeBudget {
phase: FramePhase::StateToFrame,
budget_ms: 16,
warn_on_exceed: true,
},
FrameTimeBudget {
phase: FramePhase::InputToPixels,
budget_ms: 33,
warn_on_exceed: true,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameQualityVerdict {
OnBudget,
OverBudget,
Dropped,
}
impl FrameQualityVerdict {
#[must_use]
pub const fn classify(duration_ms: u16) -> Self {
if duration_ms <= FRAME_BUDGET_MS {
Self::OnBudget
} else if duration_ms <= FRAME_DROP_THRESHOLD_MS {
Self::OverBudget
} else {
Self::Dropped
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameQualityTracker {
window: usize,
samples: Vec<u16>,
cursor: usize,
total_frames: u64,
on_budget_count: u64,
dropped_count: u64,
inline_histogram: [u32; FRAME_TIME_INLINE_HISTOGRAM_BUCKETS],
overflow_histogram: BTreeMap<u16, u32>,
}
impl FrameQualityTracker {
#[must_use]
pub fn new(window: usize) -> Self {
let window = window.max(1);
Self {
window,
samples: vec![0; window],
cursor: 0,
total_frames: 0,
on_budget_count: 0,
dropped_count: 0,
inline_histogram: [0; FRAME_TIME_INLINE_HISTOGRAM_BUCKETS],
overflow_histogram: BTreeMap::new(),
}
}
pub fn record(&mut self, duration_ms: u16) {
let window_u64 = u64::try_from(self.window).unwrap_or(u64::MAX);
if self.total_frames >= window_u64 {
self.decrement_histogram(self.samples[self.cursor]);
}
self.samples[self.cursor] = duration_ms;
self.increment_histogram(duration_ms);
self.cursor = (self.cursor + 1) % self.window;
self.total_frames += 1;
match FrameQualityVerdict::classify(duration_ms) {
FrameQualityVerdict::OnBudget => self.on_budget_count += 1,
FrameQualityVerdict::OverBudget => {}
FrameQualityVerdict::Dropped => self.dropped_count += 1,
}
}
#[must_use]
pub const fn total_frames(&self) -> u64 {
self.total_frames
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn on_budget_ratio(&self) -> f64 {
if self.total_frames == 0 {
return 1.0;
}
self.on_budget_count as f64 / self.total_frames as f64
}
#[must_use]
pub const fn dropped_count(&self) -> u64 {
self.dropped_count
}
#[must_use]
pub fn slo_met(&self) -> bool {
self.on_budget_ratio() >= 0.95
}
#[must_use]
pub fn p95_frame_time_ms(&self) -> u16 {
let window_u64 = u64::try_from(self.window).unwrap_or(u64::MAX);
let active = usize::try_from(self.total_frames.min(window_u64)).unwrap_or(self.window);
if active == 0 {
return 0;
}
let idx = active.saturating_mul(95).div_ceil(100);
let rank = idx.min(active - 1) + 1;
let mut seen = 0usize;
for (duration_ms, count) in self.inline_histogram.iter().enumerate() {
seen = seen.saturating_add(usize::try_from(*count).unwrap_or(usize::MAX));
if seen >= rank {
return u16::try_from(duration_ms).unwrap_or(u16::MAX);
}
}
for (&duration_ms, &count) in &self.overflow_histogram {
seen = seen.saturating_add(usize::try_from(count).unwrap_or(usize::MAX));
if seen >= rank {
return duration_ms;
}
}
0
}
fn increment_histogram(&mut self, duration_ms: u16) {
let bucket = usize::from(duration_ms);
if bucket < FRAME_TIME_INLINE_HISTOGRAM_BUCKETS {
self.inline_histogram[bucket] = self.inline_histogram[bucket].saturating_add(1);
} else {
let count = self.overflow_histogram.entry(duration_ms).or_insert(0);
*count = count.saturating_add(1);
}
}
fn decrement_histogram(&mut self, duration_ms: u16) {
let bucket = usize::from(duration_ms);
if bucket < FRAME_TIME_INLINE_HISTOGRAM_BUCKETS {
self.inline_histogram[bucket] = self.inline_histogram[bucket].saturating_sub(1);
return;
}
let remove = self
.overflow_histogram
.get_mut(&duration_ms)
.is_some_and(|count| {
*count = count.saturating_sub(1);
*count == 0
});
if remove {
self.overflow_histogram.remove(&duration_ms);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationTiming {
pub cursor_slide_ms: u16,
pub panel_transition_ms: u16,
pub chart_redraw_ms: u16,
pub badge_fade_ms: u16,
pub spinner_interval_ms: u16,
}
impl AnimationTiming {
pub const FULL: Self = Self {
cursor_slide_ms: 80,
panel_transition_ms: 150,
chart_redraw_ms: 200,
badge_fade_ms: 120,
spinner_interval_ms: 100,
};
pub const REDUCED: Self = Self {
cursor_slide_ms: 0,
panel_transition_ms: 0,
chart_redraw_ms: 0,
badge_fade_ms: 0,
spinner_interval_ms: 0,
};
#[must_use]
pub const fn from_preference(motion: MotionPreference) -> Self {
match motion {
MotionPreference::Full => Self::FULL,
MotionPreference::Reduced => Self::REDUCED,
}
}
#[must_use]
pub const fn from_display_preferences(prefs: &DisplayPreferences) -> Self {
Self::from_preference(prefs.motion)
}
#[must_use]
pub const fn is_instant(&self) -> bool {
self.cursor_slide_ms == 0
&& self.panel_transition_ms == 0
&& self.chart_redraw_ms == 0
&& self.badge_fade_ms == 0
&& self.spinner_interval_ms == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyboardBinding {
pub action_id: String,
pub key_label: String,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyboardParityAudit {
pub covered: Vec<KeyboardBinding>,
pub uncovered: Vec<String>,
}
impl KeyboardParityAudit {
#[must_use]
pub const fn is_complete(&self) -> bool {
self.uncovered.is_empty()
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn coverage_ratio(&self) -> f64 {
let total = self.covered.len() + self.uncovered.len();
if total == 0 {
return 1.0;
}
self.covered.len() as f64 / total as f64
}
}
pub const MAX_CONSECUTIVE_DROPS: u32 = 3;
pub const MIN_EFFECTIVE_FPS: u32 = 30;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualityConstraints {
pub frame_budget_ms: u16,
pub max_p95_frame_ms: u16,
pub min_on_budget_ratio_permille: u16,
pub max_consecutive_drops: u32,
pub enforce: bool,
}
impl Default for QualityConstraints {
fn default() -> Self {
Self {
frame_budget_ms: FRAME_BUDGET_MS,
max_p95_frame_ms: FRAME_DROP_THRESHOLD_MS,
min_on_budget_ratio_permille: 950,
max_consecutive_drops: MAX_CONSECUTIVE_DROPS,
enforce: true,
}
}
}
impl QualityConstraints {
#[must_use]
pub const fn relaxed() -> Self {
Self {
frame_budget_ms: 33,
max_p95_frame_ms: 50,
min_on_budget_ratio_permille: 900,
max_consecutive_drops: 5,
enforce: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_quality_classify() {
assert_eq!(
FrameQualityVerdict::classify(10),
FrameQualityVerdict::OnBudget
);
assert_eq!(
FrameQualityVerdict::classify(16),
FrameQualityVerdict::OnBudget
);
assert_eq!(
FrameQualityVerdict::classify(17),
FrameQualityVerdict::OverBudget
);
assert_eq!(
FrameQualityVerdict::classify(33),
FrameQualityVerdict::OverBudget
);
assert_eq!(
FrameQualityVerdict::classify(34),
FrameQualityVerdict::Dropped
);
}
#[test]
fn frame_quality_tracker_empty() {
let tracker = FrameQualityTracker::new(100);
assert_eq!(tracker.total_frames(), 0);
assert!((tracker.on_budget_ratio() - 1.0).abs() < f64::EPSILON);
assert!(tracker.slo_met());
}
#[test]
fn frame_quality_tracker_all_on_budget() {
let mut tracker = FrameQualityTracker::new(100);
for _ in 0..50 {
tracker.record(10);
}
assert_eq!(tracker.total_frames(), 50);
assert!((tracker.on_budget_ratio() - 1.0).abs() < f64::EPSILON);
assert_eq!(tracker.dropped_count(), 0);
assert!(tracker.slo_met());
}
#[test]
fn frame_quality_tracker_dropped_frames() {
let mut tracker = FrameQualityTracker::new(100);
for _ in 0..90 {
tracker.record(10);
}
for _ in 0..10 {
tracker.record(50);
}
assert_eq!(tracker.total_frames(), 100);
assert_eq!(tracker.dropped_count(), 10);
assert!((tracker.on_budget_ratio() - 0.9).abs() < f64::EPSILON);
assert!(!tracker.slo_met());
}
#[test]
fn frame_quality_tracker_p95() {
let mut tracker = FrameQualityTracker::new(100);
for i in 1_u16..=100 {
tracker.record(i);
}
let p95 = tracker.p95_frame_time_ms();
assert!((95..=100).contains(&p95));
}
#[test]
fn frame_quality_tracker_p95_matches_sort_after_wrap() {
fn reference(values: &[u16]) -> u16 {
if values.is_empty() {
return 0;
}
let mut sorted = values.to_vec();
sorted.sort_unstable();
let idx = sorted.len().saturating_mul(95).div_ceil(100);
sorted[idx.min(sorted.len() - 1)]
}
let mut tracker = FrameQualityTracker::new(8);
let mut window = Vec::new();
for duration_ms in [
12_u16,
14,
16,
21,
34,
255,
13,
15,
1024,
18,
19,
u16::MAX,
17,
11,
10,
9,
] {
tracker.record(duration_ms);
window.push(duration_ms);
if window.len() > 8 {
window.remove(0);
}
assert_eq!(tracker.p95_frame_time_ms(), reference(&window));
}
}
#[test]
fn animation_timing_full_has_nonzero_durations() {
let t = AnimationTiming::FULL;
assert!(!t.is_instant());
assert!(t.cursor_slide_ms > 0);
assert!(t.panel_transition_ms > 0);
}
#[test]
fn animation_timing_reduced_is_instant() {
let t = AnimationTiming::REDUCED;
assert!(t.is_instant());
assert_eq!(t.cursor_slide_ms, 0);
assert_eq!(t.panel_transition_ms, 0);
assert_eq!(t.spinner_interval_ms, 0);
}
#[test]
fn animation_timing_from_preference() {
assert_eq!(
AnimationTiming::from_preference(MotionPreference::Full),
AnimationTiming::FULL
);
assert_eq!(
AnimationTiming::from_preference(MotionPreference::Reduced),
AnimationTiming::REDUCED
);
}
#[test]
fn animation_timing_from_display_preferences() {
let mut prefs = DisplayPreferences::new();
assert_eq!(
AnimationTiming::from_display_preferences(&prefs),
AnimationTiming::FULL
);
prefs.toggle_motion();
assert_eq!(
AnimationTiming::from_display_preferences(&prefs),
AnimationTiming::REDUCED
);
}
#[test]
fn keyboard_parity_audit_empty_is_complete() {
let audit = KeyboardParityAudit {
covered: vec![],
uncovered: vec![],
};
assert!(audit.is_complete());
assert!((audit.coverage_ratio() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn keyboard_parity_audit_with_gaps() {
let audit = KeyboardParityAudit {
covered: vec![
KeyboardBinding {
action_id: "nav.fleet".to_string(),
key_label: "1".to_string(),
scope: None,
},
KeyboardBinding {
action_id: "nav.project".to_string(),
key_label: "4".to_string(),
scope: None,
},
],
uncovered: vec!["some.action".to_string()],
};
assert!(!audit.is_complete());
let ratio = audit.coverage_ratio();
assert!((ratio - 2.0 / 3.0).abs() < 0.01);
}
#[test]
fn quality_constraints_default() {
let qc = QualityConstraints::default();
assert_eq!(qc.frame_budget_ms, 16);
assert_eq!(qc.max_p95_frame_ms, 33);
assert_eq!(qc.min_on_budget_ratio_permille, 950);
assert!(qc.enforce);
}
#[test]
fn quality_constraints_relaxed() {
let qc = QualityConstraints::relaxed();
assert_eq!(qc.frame_budget_ms, 33);
assert!(!qc.enforce);
}
#[test]
fn frame_phase_labels() {
assert_eq!(FramePhase::InputToIntent.label(), "Input → Intent");
assert_eq!(FramePhase::StateToFrame.label(), "State → Frame");
}
#[test]
fn frame_budgets_cover_all_phases() {
assert!(
FRAME_BUDGETS
.iter()
.any(|b| b.phase == FramePhase::InputToIntent)
);
assert!(
FRAME_BUDGETS
.iter()
.any(|b| b.phase == FramePhase::IntentToState)
);
assert!(
FRAME_BUDGETS
.iter()
.any(|b| b.phase == FramePhase::StateToFrame)
);
assert!(
FRAME_BUDGETS
.iter()
.any(|b| b.phase == FramePhase::InputToPixels)
);
}
#[test]
fn frame_budgets_are_monotonically_increasing() {
let budgets: Vec<u16> = FRAME_BUDGETS.iter().map(|b| b.budget_ms).collect();
for window in budgets.windows(2) {
assert!(window[0] <= window[1], "budgets should be non-decreasing");
}
}
#[test]
fn frame_phase_serde_roundtrip() {
for phase in [
FramePhase::InputToIntent,
FramePhase::IntentToState,
FramePhase::StateToFrame,
FramePhase::InputToPixels,
] {
let json = serde_json::to_string(&phase).unwrap();
let decoded: FramePhase = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, phase);
}
}
#[test]
fn quality_constraints_serde_roundtrip() {
let qc = QualityConstraints::default();
let json = serde_json::to_string(&qc).unwrap();
let decoded: QualityConstraints = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, qc);
}
}