Skip to main content

ferroui_ios/
display_link_timer.rs

1//! The render timer of the platform: a display link on a thread of its
2//! own, which ticks once per frame of the display, up to 120 Hz on
3//! ProMotion displays, and is paused while the render loop is idle or the
4//! application is in the background.
5
6use crate::interop::{kCFRunLoopCommonModes, CFRunLoopGetCurrent, CFRunLoopPerformBlock, CFRunLoopWakeUp};
7use crate::platform::Platform;
8use block2::RcBlock;
9use ferroui_base::logging::{LogArea, LogEventLevel, Logger};
10use ferroui_base::rendering::{IRenderTimer, RenderTimerTick};
11use objc2::rc::{autoreleasepool, Retained};
12use objc2::{define_class, msg_send, sel, AllocAnyThread, DefinedClass, MainThreadMarker};
13use objc2_foundation::{
14    NSBundle, NSNotification, NSNotificationCenter, NSNotificationName, NSNumber, NSObject, NSObjectProtocol,
15    NSRunLoop, NSRunLoopCommonModes, NSString,
16};
17use objc2_quartz_core::{CADisplayLink, CAFrameRateRange};
18use objc2_ui_kit::{
19    UIApplicationDidEnterBackgroundNotification, UIApplicationWillEnterForegroundNotification, UIDevice, UIScreen,
20    UIUserInterfaceIdiom,
21};
22use std::cell::RefCell;
23use std::ffi::c_void;
24use std::ptr::NonNull;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
27use std::thread::Thread;
28use std::time::Instant;
29
30/// What the render loop, the main thread and the link thread share, under the lock of the timer.
31struct LinkState {
32    tick: Option<RenderTimerTick>,
33    in_background: bool,
34    paused: bool,
35    /// The run loop of the timer thread (a `CFRunLoopRef`), once the link is on it.
36    run_loop: Option<usize>,
37}
38
39/// What the timer and the target of its display link share.
40struct TimerState {
41    // Guards tick, in_background and paused, which the render loop, the main thread and the
42    // link thread share.
43    lock: Mutex<LinkState>,
44    /// The number of times the display link fired.
45    ticks: AtomicU64,
46    st: Instant,
47}
48
49impl TimerState {
50    fn lock(&self) -> MutexGuard<'_, LinkState> {
51        self.lock.lock().unwrap_or_else(PoisonError::into_inner)
52    }
53
54    fn on_link_tick(&self) {
55        self.ticks.fetch_add(1, Ordering::SeqCst);
56        // A callback already scheduled when the link was paused can still arrive.
57        let tick = {
58            let state = self.lock();
59            if state.paused {
60                None
61            } else {
62                state.tick.clone()
63            }
64        };
65        if let Some(tick) = tick {
66            tick(self.st.elapsed());
67        }
68    }
69}
70
71ferroui_base::ferro_thread_local! {
72    /// The display link, an object of the timer thread (set on that thread only).
73    static LINK: RefCell<Option<Retained<CADisplayLink>>> = const { RefCell::new(None) };
74}
75
76/// Called with the lock held. The reference sets `CADisplayLink.Paused` here, from whichever
77/// thread changed the state; the link of the port is an object of the timer thread, so the
78/// change is applied there (`apply_link_state`), through the run loop of that thread, which may
79/// be given work from any thread.
80fn update_link_state(this: &Arc<TimerState>, state: &mut LinkState) {
81    let paused = state.in_background || state.tick.is_none();
82    if state.paused != paused {
83        state.paused = paused;
84        if let Some(run_loop) = state.run_loop {
85            schedule_apply_link_state(this.clone(), run_loop);
86        }
87    }
88}
89
90fn schedule_apply_link_state(this: Arc<TimerState>, run_loop: usize) {
91    let block = RcBlock::new(move || apply_link_state(&this));
92    // SAFETY: the run loop is the one of the timer thread, which runs for the life of the
93    // process; `CFRunLoopRef` may be given work and woken up from any thread. The block takes no
94    // argument and returns nothing, as `CFRunLoopPerformBlock` calls it, and the function copies
95    // it, so the reference given back here is the caller's own. The mode is a constant of Core
96    // Foundation.
97    unsafe {
98        CFRunLoopPerformBlock(
99            run_loop as *mut c_void,
100            kCFRunLoopCommonModes,
101            &*block as *const block2::Block<dyn Fn()> as *const c_void,
102        );
103        CFRunLoopWakeUp(run_loop as *mut c_void);
104    }
105}
106
107/// On the timer thread: pauses or resumes the link as the state says.
108fn apply_link_state(this: &TimerState) {
109    let paused = this.lock().paused;
110    LINK.with(|link| {
111        if let Some(link) = link.borrow().as_ref() {
112            link.setPaused(paused);
113        }
114    });
115}
116
117define_class!(
118    // SAFETY: `NSObject` has no requirements on a subclass, and the class
119    // does not implement `Drop`.
120    #[unsafe(super(NSObject))]
121    #[name = "FerroDisplayLinkTarget"]
122    #[ivars = Arc<TimerState>]
123    struct DisplayLinkTarget;
124
125    impl DisplayLinkTarget {
126        #[unsafe(method(onLinkTick:))]
127        fn on_link_tick(&self, _link: &CADisplayLink) {
128            // The run loop of a thread that is not the main thread drains
129            // no pool between its turns.
130            autoreleasepool(|_| self.ivars().on_link_tick());
131        }
132    }
133
134    unsafe impl NSObjectProtocol for DisplayLinkTarget {}
135);
136
137impl DisplayLinkTarget {
138    fn new(state: Arc<TimerState>) -> Retained<Self> {
139        let this = Self::alloc().set_ivars(state);
140        // SAFETY: `init` of the superclass, on the object that was just
141        // allocated and whose instance variables are set.
142        unsafe { msg_send![super(this), init] }
143    }
144}
145
146/// Render timer driven by a `CADisplayLink`, paused while the render loop is idle or the
147/// application is in the background.
148pub struct DisplayLinkTimer {
149    state: Arc<TimerState>,
150    timer_thread: Thread,
151}
152
153impl DisplayLinkTimer {
154    /// Creates the timer and starts its thread. The options of the platform
155    /// ([`Platform::options`]) are read here, so the platform has them first.
156    ///
157    /// # Panics
158    /// Panics when the thread cannot be started.
159    pub fn new() -> Arc<DisplayLinkTimer> {
160        let state = Arc::new(TimerState {
161            lock: Mutex::new(LinkState { tick: None, in_background: false, paused: true, run_loop: None }),
162            ticks: AtomicU64::new(0),
163            st: Instant::now(),
164        });
165        let enable_high_refresh_rate = Self::enable_high_refresh_rate();
166
167        // The reference creates the link on the thread that registers the
168        // platform and adds it to the run loop of the timer thread. Here the
169        // link is an object of the timer thread from the start: it is
170        // created, scheduled, paused and resumed there, and no other thread
171        // touches it (DEVIATIONS.md, iOS backend).
172        let thread_state = state.clone();
173        let spawned = std::thread::Builder::new().name("DisplayLinkTimer".to_string()).spawn(move || {
174            let target = DisplayLinkTarget::new(thread_state.clone());
175            // SAFETY: the target is an object that responds to the selector
176            // (`onLinkTick:` above takes the link, the one argument a
177            // display link passes); the link retains its target. The mode
178            // is a constant of Foundation, and the run loop is the one of
179            // this thread.
180            unsafe {
181                let link = CADisplayLink::displayLinkWithTarget_selector(&target, sel!(onLinkTick:));
182                link.setPaused(true);
183                if enable_high_refresh_rate {
184                    Self::set_preferred_frame_rate_range(&link);
185                }
186                let run_loop = NSRunLoop::currentRunLoop();
187                link.addToRunLoop_forMode(&run_loop, NSRunLoopCommonModes);
188                LINK.with(|slot| *slot.borrow_mut() = Some(link));
189                // From now on a change of the state is applied on this thread; the state of
190                // before is applied once here.
191                thread_state.lock().run_loop = Some(CFRunLoopGetCurrent() as usize);
192                apply_link_state(&thread_state);
193                run_loop.run();
194            }
195        });
196        let timer_thread = match spawned {
197            Ok(handle) => handle.thread().clone(),
198            Err(error) => panic!("Unable to start the thread of the render timer: {error}"),
199        };
200
201        let timer = Arc::new(DisplayLinkTimer { state, timer_thread });
202        if enable_high_refresh_rate {
203            timer.warn_if_limited_to_60_hz();
204        }
205
206        // SAFETY: the two names are constants of UIKit.
207        let (entered_background, will_enter_foreground) =
208            unsafe { (UIApplicationDidEnterBackgroundNotification, UIApplicationWillEnterForegroundNotification) };
209        Self::observe(entered_background, timer.state.clone(), true);
210        Self::observe(will_enter_foreground, timer.state.clone(), false);
211
212        timer
213    }
214
215    /// The thread of the display link: where the timer ticks and, the
216    /// timer running in the background, where the compositor renders.
217    pub fn timer_thread(&self) -> &Thread {
218        &self.timer_thread
219    }
220
221    /// `iOSPlatformOptions.EnableHighRefreshRate` of the options of the platform; false when the
222    /// platform has no options yet (`Platform.Options is { EnableHighRefreshRate: true }`).
223    fn enable_high_refresh_rate() -> bool {
224        Platform::options().is_some_and(|options| options.enable_high_refresh_rate)
225    }
226
227    /// Lets `link` follow the display refresh rate instead of the 60 Hz default; the caller
228    /// asks [`IosPlatformOptions::enable_high_refresh_rate`](crate::IosPlatformOptions) first.
229    /// On iPhone, this also needs CADisableMinimumFrameDurationOnPhone in the app's Info.plist.
230    /// The reference asks for iOS, tvOS or Mac Catalyst 15, the systems with
231    /// `preferredFrameRateRange`; the port asks the link whether it has it.
232    fn set_preferred_frame_rate_range(link: &CADisplayLink) {
233        if link.respondsToSelector(sel!(setPreferredFrameRateRange:)) {
234            link.setPreferredFrameRateRange(CAFrameRateRange { minimum: 60.0, maximum: 120.0, preferred: 120.0 });
235        }
236    }
237
238    /// Warns when the display can go above 60 Hz and the application's Info.plist does not
239    /// allow it. Only on the main thread, where UIKit may be asked.
240    fn warn_if_limited_to_60_hz(&self) {
241        let Some(mtm) = MainThreadMarker::new() else {
242            return;
243        };
244        if UIDevice::currentDevice(mtm).userInterfaceIdiom() != UIUserInterfaceIdiom::Phone {
245            return;
246        }
247        if UIScreen::mainScreen(mtm).maximumFramesPerSecond() <= 60 {
248            return;
249        }
250        let key = NSString::from_str("CADisableMinimumFrameDurationOnPhone");
251        let allowed = NSBundle::mainBundle()
252            .objectForInfoDictionaryKey(&key)
253            .and_then(|value| value.downcast::<NSNumber>().ok())
254            .is_some_and(|value| value.boolValue());
255        if !allowed {
256            if let Some(logger) = Logger::try_get(LogEventLevel::Warning, LogArea::IOS_PLATFORM) {
257                logger.log(
258                    Some(self as &dyn std::any::Any),
259                    "Rendering is limited to 60 Hz on this ProMotion display. Set CADisableMinimumFrameDurationOnPhone \
260                     to true in Info.plist, or set IosPlatformOptions::enable_high_refresh_rate to false.",
261                );
262            }
263        }
264    }
265
266    fn set_in_background(state: &Arc<TimerState>, in_background: bool) {
267        let mut link_state = state.lock();
268        link_state.in_background = in_background;
269        update_link_state(state, &mut link_state);
270    }
271
272    fn observe(name: &NSNotificationName, state: Arc<TimerState>, in_background: bool) {
273        let block = RcBlock::new(move |_notification: NonNull<NSNotification>| {
274            Self::set_in_background(&state, in_background);
275        });
276        // SAFETY: the block takes the one argument of a notification block
277        // and ignores it, and what it captures is shared state that any
278        // thread may change (under the lock of the timer), so the block may
279        // be called on whichever thread posts the notification.
280        let observer = unsafe {
281            NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
282                Some(name),
283                None,
284                None,
285                &block,
286            )
287        };
288        // The timer lives as long as the process, and so does the
289        // subscription.
290        std::mem::forget(observer);
291    }
292}
293
294impl DisplayLinkTimer {
295    /// The number of times the display link fired, whether or not a frame
296    /// was asked for. An addition of the port, for diagnostics.
297    pub fn ticks(&self) -> u64 {
298        self.state.ticks.load(Ordering::SeqCst)
299    }
300
301    /// Whether a tick handler is set: whether the render loop runs.
302    pub fn has_tick(&self) -> bool {
303        self.state.lock().tick.is_some()
304    }
305}
306
307impl IRenderTimer for DisplayLinkTimer {
308    fn tick(&self) -> Option<RenderTimerTick> {
309        self.state.lock().tick.clone()
310    }
311
312    fn set_tick(&self, value: Option<RenderTimerTick>) {
313        let mut link_state = self.state.lock();
314        link_state.tick = value;
315        update_link_state(&self.state, &mut link_state);
316    }
317
318    fn runs_in_background(&self) -> bool {
319        true
320    }
321}