use std::time::{Duration, Instant};
use ftui_core::geometry::Rect;
use ftui_layout::{Constraint, Flex};
use tracing::warn;
pub type JankCallback = Box<dyn Fn(&FrameMetrics) + Send>;
#[derive(Debug, Clone)]
pub struct FrameMetrics {
pub frame_number: u64,
pub render_duration: Duration,
pub budget: Duration,
pub is_jank: bool,
pub timestamp: Instant,
}
impl FrameMetrics {
#[must_use]
pub const fn overshoot(&self) -> Duration {
self.render_duration.saturating_sub(self.budget)
}
#[must_use]
pub fn budget_ratio(&self) -> f64 {
if self.budget.is_zero() {
return f64::INFINITY;
}
self.render_duration.as_secs_f64() / self.budget.as_secs_f64()
}
}
pub struct FrameBudget {
target: Duration,
frame_number: u64,
frame_start: Option<Instant>,
jank_callback: Option<JankCallback>,
jank_count: u64,
total_frames: u64,
}
impl FrameBudget {
#[must_use]
pub fn new(target: Duration) -> Self {
Self {
target,
frame_number: 0,
frame_start: None,
jank_callback: None,
jank_count: 0,
total_frames: 0,
}
}
#[must_use]
pub fn at_60fps() -> Self {
Self::new(Duration::from_micros(16_667))
}
#[must_use]
pub fn at_30fps() -> Self {
Self::new(Duration::from_micros(33_333))
}
pub fn on_jank(&mut self, callback: JankCallback) {
self.jank_callback = Some(callback);
}
pub fn begin_frame(&mut self) {
self.frame_start = Some(Instant::now());
}
pub fn end_frame(&mut self) -> FrameMetrics {
let Some(start) = self.frame_start.take() else {
warn!(
target: "frankensearch.tui.frame",
"end_frame called without begin_frame; returning empty metrics"
);
return FrameMetrics {
frame_number: self.frame_number,
render_duration: Duration::ZERO,
budget: self.target,
is_jank: false,
timestamp: Instant::now(),
};
};
let render_duration = start.elapsed();
let is_jank = render_duration > self.target;
self.frame_number += 1;
self.total_frames += 1;
if is_jank {
self.jank_count += 1;
}
let metrics = FrameMetrics {
frame_number: self.frame_number,
render_duration,
budget: self.target,
is_jank,
timestamp: start,
};
if is_jank && let Some(cb) = &self.jank_callback {
cb(&metrics);
}
metrics
}
#[must_use]
pub const fn target(&self) -> Duration {
self.target
}
#[must_use]
pub const fn total_frames(&self) -> u64 {
self.total_frames
}
#[must_use]
pub const fn jank_count(&self) -> u64 {
self.jank_count
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn jank_rate(&self) -> f64 {
if self.total_frames == 0 {
return 0.0;
}
self.jank_count as f64 / self.total_frames as f64
}
}
#[derive(Debug, Clone)]
pub struct FramePipelineMetrics {
pub input_to_intent: Duration,
pub intent_to_state: Duration,
pub state_to_frame: Duration,
pub total: Duration,
pub input_to_intent_ok: bool,
pub intent_to_state_ok: bool,
pub state_to_frame_ok: bool,
}
impl FramePipelineMetrics {
#[must_use]
pub const fn all_within_budget(&self) -> bool {
self.input_to_intent_ok && self.intent_to_state_ok && self.state_to_frame_ok
}
}
pub struct FramePipelineTimer {
input_budget: Duration,
state_budget: Duration,
render_budget: Duration,
input_start: Option<Instant>,
state_start: Option<Instant>,
render_start: Option<Instant>,
input_duration: Duration,
state_duration: Duration,
}
impl FramePipelineTimer {
#[must_use]
pub const fn new(
input_budget: Duration,
state_budget: Duration,
render_budget: Duration,
) -> Self {
Self {
input_budget,
state_budget,
render_budget,
input_start: None,
state_start: None,
render_start: None,
input_duration: Duration::ZERO,
state_duration: Duration::ZERO,
}
}
#[must_use]
pub const fn with_default_budgets() -> Self {
Self::new(
Duration::from_millis(8),
Duration::from_millis(12),
Duration::from_millis(16),
)
}
pub fn begin_input(&mut self) {
self.input_start = Some(Instant::now());
}
pub fn end_input_begin_state(&mut self) {
if let Some(start) = self.input_start.take() {
self.input_duration = start.elapsed();
}
self.state_start = Some(Instant::now());
}
pub fn end_state_begin_render(&mut self) {
if let Some(start) = self.state_start.take() {
self.state_duration = start.elapsed();
}
self.render_start = Some(Instant::now());
}
pub fn end_render(&mut self) -> FramePipelineMetrics {
let render_duration = self
.render_start
.take()
.map_or(Duration::ZERO, |s| s.elapsed());
let total = self.input_duration + self.state_duration + render_duration;
FramePipelineMetrics {
input_to_intent: self.input_duration,
intent_to_state: self.state_duration,
state_to_frame: render_duration,
total,
input_to_intent_ok: self.input_duration <= self.input_budget,
intent_to_state_ok: self.state_duration <= self.state_budget,
state_to_frame_ok: render_duration <= self.render_budget,
}
}
pub fn skip_input_begin_state(&mut self) {
self.input_duration = Duration::ZERO;
self.state_start = Some(Instant::now());
}
}
#[derive(Debug, Clone)]
pub struct CachedLayout {
area: Rect,
show_breadcrumbs: bool,
show_status_bar: bool,
screen_count: usize,
chunks: Vec<Rect>,
}
impl CachedLayout {
#[must_use]
pub const fn new() -> Self {
Self {
area: Rect::new(0, 0, 0, 0),
show_breadcrumbs: false,
show_status_bar: false,
screen_count: 0,
chunks: Vec::new(),
}
}
pub fn get_or_compute(
&mut self,
area: Rect,
show_breadcrumbs: bool,
show_status_bar: bool,
screen_count: usize,
) -> &[Rect] {
let breadcrumbs_visible = show_breadcrumbs && screen_count > 1;
if self.area == area
&& self.show_breadcrumbs == breadcrumbs_visible
&& self.show_status_bar == show_status_bar
&& self.screen_count == screen_count
{
return &self.chunks;
}
let mut constraints = Vec::with_capacity(3);
if breadcrumbs_visible {
constraints.push(Constraint::Fixed(1));
}
constraints.push(Constraint::Min(1));
if show_status_bar {
constraints.push(Constraint::Fixed(1));
}
self.chunks = Flex::vertical().constraints(constraints).split(area);
self.area = area;
self.show_breadcrumbs = breadcrumbs_visible;
self.show_status_bar = show_status_bar;
self.screen_count = screen_count;
&self.chunks
}
pub const fn invalidate(&mut self) {
self.area = Rect::new(0, 0, 0, 0);
}
}
impl Default for CachedLayout {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct CachedTabState {
pub titles: Vec<String>,
pub selected: usize,
screen_ids_hash: u64,
title_signature: u64,
active_screen: Option<String>,
}
impl CachedTabState {
#[must_use]
pub const fn new() -> Self {
Self {
titles: Vec::new(),
selected: 0,
screen_ids_hash: 0,
title_signature: 0,
active_screen: None,
}
}
#[must_use]
pub fn is_valid(
&self,
screen_ids_hash: u64,
title_signature: u64,
active_screen: Option<&str>,
) -> bool {
self.screen_ids_hash == screen_ids_hash
&& self.title_signature == title_signature
&& self.active_screen.as_deref() == active_screen
}
pub fn update(
&mut self,
titles: Vec<String>,
selected: usize,
screen_ids_hash: u64,
title_signature: u64,
active_screen: Option<&str>,
) {
self.titles = titles;
self.selected = selected;
self.screen_ids_hash = screen_ids_hash;
self.title_signature = title_signature;
self.active_screen = active_screen.map(String::from);
}
pub fn invalidate(&mut self) {
self.screen_ids_hash = 0;
self.title_signature = 0;
self.active_screen = None;
}
}
impl Default for CachedTabState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
#[test]
fn frame_budget_creation() {
let budget = FrameBudget::new(Duration::from_millis(16));
assert_eq!(budget.target(), Duration::from_millis(16));
assert_eq!(budget.total_frames(), 0);
assert_eq!(budget.jank_count(), 0);
}
#[test]
fn frame_budget_60fps() {
let budget = FrameBudget::at_60fps();
assert_eq!(budget.target(), Duration::from_micros(16_667));
}
#[test]
fn frame_budget_30fps() {
let budget = FrameBudget::at_30fps();
assert_eq!(budget.target(), Duration::from_micros(33_333));
}
#[test]
fn frame_budget_begin_end() {
let mut budget = FrameBudget::new(Duration::from_secs(10)); budget.begin_frame();
let metrics = budget.end_frame();
assert_eq!(metrics.frame_number, 1);
assert!(!metrics.is_jank);
assert_eq!(budget.total_frames(), 1);
assert_eq!(budget.jank_count(), 0);
}
#[test]
fn end_frame_without_begin_is_safe_noop() {
let mut budget = FrameBudget::new(Duration::from_millis(16));
let metrics = budget.end_frame();
assert_eq!(metrics.render_duration, Duration::ZERO);
assert!(!metrics.is_jank);
assert_eq!(metrics.frame_number, 0);
assert_eq!(budget.total_frames(), 0);
assert_eq!(budget.jank_count(), 0);
}
#[test]
fn frame_metrics_overshoot() {
let metrics = FrameMetrics {
frame_number: 1,
render_duration: Duration::from_millis(20),
budget: Duration::from_millis(16),
is_jank: true,
timestamp: Instant::now(),
};
assert_eq!(metrics.overshoot(), Duration::from_millis(4));
}
#[test]
fn frame_metrics_budget_ratio() {
let metrics = FrameMetrics {
frame_number: 1,
render_duration: Duration::from_millis(32),
budget: Duration::from_millis(16),
is_jank: true,
timestamp: Instant::now(),
};
let ratio = metrics.budget_ratio();
assert!((ratio - 2.0).abs() < 0.01);
}
#[test]
fn frame_metrics_no_overshoot() {
let metrics = FrameMetrics {
frame_number: 1,
render_duration: Duration::from_millis(10),
budget: Duration::from_millis(16),
is_jank: false,
timestamp: Instant::now(),
};
assert_eq!(metrics.overshoot(), Duration::ZERO);
}
#[test]
fn jank_rate_zero_frames() {
let budget = FrameBudget::new(Duration::from_millis(16));
assert!(budget.jank_rate().abs() < f64::EPSILON);
}
#[test]
fn jank_callback_fires() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let fired = Arc::new(AtomicBool::new(false));
let fired_clone = Arc::clone(&fired);
let mut budget = FrameBudget::new(Duration::ZERO); budget.on_jank(Box::new(move |_metrics| {
fired_clone.store(true, Ordering::Relaxed);
}));
budget.begin_frame();
std::thread::sleep(Duration::from_millis(1));
let _metrics = budget.end_frame();
assert!(fired.load(Ordering::Relaxed));
}
#[test]
fn pipeline_timer_default_budgets() {
let timer = FramePipelineTimer::with_default_budgets();
assert_eq!(timer.input_budget, Duration::from_millis(8));
assert_eq!(timer.state_budget, Duration::from_millis(12));
assert_eq!(timer.render_budget, Duration::from_millis(16));
}
#[test]
fn pipeline_timer_full_cycle() {
let mut timer = FramePipelineTimer::new(
Duration::from_secs(10),
Duration::from_secs(10),
Duration::from_secs(10),
);
timer.begin_input();
timer.end_input_begin_state();
timer.end_state_begin_render();
let metrics = timer.end_render();
assert!(metrics.all_within_budget());
assert!(metrics.input_to_intent < Duration::from_millis(100));
assert!(metrics.intent_to_state < Duration::from_millis(100));
assert!(metrics.state_to_frame < Duration::from_millis(100));
}
#[test]
fn pipeline_timer_skip_input() {
let mut timer = FramePipelineTimer::with_default_budgets();
timer.skip_input_begin_state();
timer.end_state_begin_render();
let metrics = timer.end_render();
assert_eq!(metrics.input_to_intent, Duration::ZERO);
assert!(metrics.input_to_intent_ok);
}
#[test]
fn pipeline_metrics_budget_violation() {
let metrics = FramePipelineMetrics {
input_to_intent: Duration::from_millis(10),
intent_to_state: Duration::from_millis(5),
state_to_frame: Duration::from_millis(5),
total: Duration::from_millis(20),
input_to_intent_ok: false,
intent_to_state_ok: true,
state_to_frame_ok: true,
};
assert!(!metrics.all_within_budget());
}
#[test]
fn cached_layout_computes_on_first_call() {
let mut cache = CachedLayout::new();
let area = Rect::new(0, 0, 100, 40);
let chunks = cache.get_or_compute(area, true, true, 3);
assert_eq!(chunks.len(), 3);
}
#[test]
fn cached_layout_returns_cached_on_same_input() {
let mut cache = CachedLayout::new();
let area = Rect::new(0, 0, 100, 40);
let first = cache.get_or_compute(area, true, true, 3).to_vec();
let second = cache.get_or_compute(area, true, true, 3).to_vec();
assert_eq!(first, second);
}
#[test]
fn cached_layout_recomputes_on_area_change() {
let mut cache = CachedLayout::new();
let chunks1 = cache
.get_or_compute(Rect::new(0, 0, 100, 40), false, true, 1)
.to_vec();
let chunks2 = cache
.get_or_compute(Rect::new(0, 0, 120, 50), false, true, 1)
.to_vec();
assert_ne!(chunks1[0].height, chunks2[0].height);
}
#[test]
fn cached_layout_no_breadcrumbs_single_screen() {
let mut cache = CachedLayout::new();
let chunks = cache.get_or_compute(Rect::new(0, 0, 80, 24), true, true, 1);
assert_eq!(chunks.len(), 2); }
#[test]
fn cached_layout_invalidate() {
let mut cache = CachedLayout::new();
let area = Rect::new(0, 0, 100, 40);
let _ = cache.get_or_compute(area, false, true, 1);
cache.invalidate();
assert_eq!(cache.area, Rect::new(0, 0, 0, 0));
}
#[test]
fn cached_tab_state_initially_invalid() {
let cache = CachedTabState::new();
assert!(!cache.is_valid(12345, 777, Some("test")));
}
#[test]
fn cached_tab_state_valid_after_update() {
let mut cache = CachedTabState::new();
cache.update(vec!["A".into(), "B".into()], 1, 999, 4242, Some("B"));
assert!(cache.is_valid(999, 4242, Some("B")));
assert_eq!(cache.selected, 1);
assert_eq!(cache.titles, vec!["A", "B"]);
}
#[test]
fn cached_tab_state_invalidate() {
let mut cache = CachedTabState::new();
cache.update(vec!["X".into()], 0, 42, 17, Some("X"));
assert!(cache.is_valid(42, 17, Some("X")));
cache.invalidate();
assert!(!cache.is_valid(42, 17, Some("X")));
}
#[test]
fn cached_tab_state_detects_screen_change() {
let mut cache = CachedTabState::new();
cache.update(vec!["A".into()], 0, 100, 200, Some("A"));
assert!(!cache.is_valid(101, 200, Some("A")));
assert!(!cache.is_valid(100, 201, Some("A")));
assert!(!cache.is_valid(100, 200, Some("B")));
}
}