ltk/event_loop/
perf.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Runtime performance guardrails.
//!
//! The idle/redraw model is efficient only if the app cooperates — a stuck
//! [`App::is_animating`](crate::App::is_animating), an aggressive
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
//! the software backend all quietly burn CPU/battery. The docs warn about
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
//! on the software renderer, opt-out via
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).

use std::sync::OnceLock;
use std::time::{ Duration, Instant };

/// A settled animation should return `false` well before this; staying `true`
/// this long is almost always a forgotten `is_animating = false`.
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
/// Continuous software-rendered animation past this is worth flagging on
/// mobile, where it means sustained CPU with no GPU offload.
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
/// A `poll_interval` shorter than this defeats the event-driven idle model.
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );

/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
fn perf_warn_enabled() -> bool
{
	static ENABLED: OnceLock<bool> = OnceLock::new();
	*ENABLED.get_or_init( ||
		std::env::var( "LTK_PERF_WARN" )
			.map( |v| !v.is_empty() && v != "0" )
			.unwrap_or( false )
	)
}

/// Per-runtime performance-guard state. Lives on `AppData`.
pub struct PerfState
{
	/// When the current continuous animation began (`None` while idle).
	animating_since:      Option<Instant>,
	/// Last software-backend animation re-raster, for the 30 Hz cap.
	last_anim_draw:       Instant,
	warned_stuck:         bool,
	warned_software_anim: bool,
	warned_poll:          bool,
}

impl PerfState
{
	pub fn new() -> Self
	{
		Self
		{
			animating_since:      None,
			// Start in the past so the first animated frame is never throttled.
			// `checked_sub` guards against underflow shortly after boot.
			last_anim_draw:       Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
			warned_stuck:         false,
			warned_software_anim: false,
			warned_poll:          false,
		}
	}

	/// Reset when the app stops animating, so the next animation starts fresh
	/// (and can warn again if it, too, gets stuck).
	pub fn animation_stopped( &mut self )
	{
		self.animating_since      = None;
		self.warned_stuck         = false;
		self.warned_software_anim = false;
	}

	/// Called on every animated main-surface frame callback. Runs the opt-in
	/// diagnostics and applies the software animation-rate cap. Returns `true`
	/// if this frame should actually re-raster, `false` to throttle-skip it
	/// (the caller keeps the vsync cadence with a bare frame callback).
	pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
	{
		let since = *self.animating_since.get_or_insert_with( Instant::now );

		if perf_warn_enabled()
		{
			if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
			{
				eprintln!(
					"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
					 animation must return false, or the loop redraws at the display rate \
					 forever (battery drain).",
					STUCK_THRESHOLD.as_secs(),
				);
				self.warned_stuck = true;
			}
			if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
			{
				eprintln!(
					"[ltk][perf] continuous animation on the software renderer — sustained \
					 CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
					 or a GLES-capable compositor.",
				);
				self.warned_software_anim = true;
			}
		}

		if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
		{
			return false;
		}
		self.last_anim_draw = Instant::now();
		true
	}

	/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
	/// enough to defeat the idle model.
	pub fn warn_poll_interval( &mut self, dur: Duration )
	{
		if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
		{
			eprintln!(
				"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
				 wake the loop from your worker with set_channel_sender instead of polling.",
				dur.as_millis(),
			);
			self.warned_poll = true;
		}
	}
}

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

	#[ test ]
	fn gles_animation_is_never_capped()
	{
		let mut p = PerfState::new();
		// Two back-to-back frames on the GPU backend both re-raster.
		assert!( p.animated_frame( false, true ) );
		assert!( p.animated_frame( false, true ) );
	}

	#[ test ]
	fn software_animation_is_capped_between_rerasters()
	{
		let mut p = PerfState::new();
		// First software frame draws; an immediate second is throttled
		// (< 33 ms since the last re-raster).
		assert!(  p.animated_frame( true, true ) );
		assert!( !p.animated_frame( true, true ) );
	}

	#[ test ]
	fn software_cap_opt_out_keeps_full_rate()
	{
		let mut p = PerfState::new();
		assert!( p.animated_frame( true, false ) );
		assert!( p.animated_frame( true, false ) );
	}

	#[ test ]
	fn stopping_resets_stuck_warning_state()
	{
		let mut p = PerfState::new();
		let _ = p.animated_frame( false, true );
		assert!( p.animating_since.is_some() );
		p.animation_stopped();
		assert!( p.animating_since.is_none() );
	}
}