telar 0.1.5

A modular Rust UI framework with its own template language, reactive signals and a self-contained renderer.
Documentation
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! The frame pipeline's render thread: the worker every backend hands its composed commands to.
//!
//! Lives here rather than in `hot_host`, which is compiled only under `dev` and named for hot reload —
//! this is the always-compiled core of the frame loop, and `handler.rs` imports it on every build.

use renderer_core::RenderBackend;

use super::FRAME_BUDGET;

pub(super) struct FrameMsg {
    pub(super) width: u32,
    pub(super) height: u32,
    pub(super) scale_factor: f32,
    pub(super) generation: u64,
    pub(super) commands: Vec<renderer_core::DrawCommand>,
    pub(super) clear: Option<renderer_core::Color>,
    pub(super) timestamp: std::time::Instant,
}

/// Drives `renderer` on a thread of its own, fed one [`FrameMsg`] at a time.
///
/// Generic over the backend so the software rasteriser gets the same pipeline the hardware one has always
/// had — stale-frame dropping, buffer recycling, `catch_unwind`, and an ADPF session keyed to the thread that
/// actually does the work. Staying generic (rather than boxing) is what lets `on_suspend` join and reclaim
/// the *concrete* renderer, which is how the hardware path keeps its device, pipelines and caches warm.
///
/// **Frames here are droppable.** Anything added to this loop has to tolerate a frame never arriving: the
/// stale-frame gate below skips whole frames whenever the UI thread outruns the renderer, so no step may
/// leave a side effect half-applied for the next one to finish.
///
/// Only the UI-thread side of the boundary is `!Send`-constrained; nothing reactive crosses. What arrives is
/// flat data plus `Arc`s, and the proof of that is simply that this compiles.
pub(super) fn spawn_render_thread<R>(
    renderer: R,
) -> (
    std::sync::mpsc::SyncSender<FrameMsg>,
    std::sync::mpsc::Receiver<Vec<renderer_core::DrawCommand>>,
    std::thread::JoinHandle<R>,
)
where
    R: RenderBackend + Send + 'static,
{
    let (tx, rx) = std::sync::mpsc::sync_channel::<FrameMsg>(1);
    // F2: hand the consumed command buffer back to the UI thread so it refills the same allocation
    // next frame instead of freeing it here and allocating a fresh Vec every frame.
    let (ret_tx, ret_rx) = std::sync::mpsc::channel::<Vec<renderer_core::DrawCommand>>();
    let join = std::thread::Builder::new()
        .name("telar-render".to_string())
        .spawn(move || {
            let mut renderer = renderer;
            // Before anything is drawn: whatever per-thread state the constructor set up on the UI thread has
            // to exist here too, or the first frame finds it empty and improvises.
            renderer.bind_to_render_thread();
            let mut current_width = 0u32;
            let mut current_height = 0u32;
            // HiDPI scaling for backends that don't fold it into a shader, kept off the UI thread: for the
            // software path it is the largest per-frame cost that would otherwise sit in front of input.
            let mut scale_scratch = renderer_core::ScaleScratch::new();
            let scales_itself = renderer.applies_scale_factor();
            // ADPF lives on THIS thread: create the hint session with the render thread's own TID (None self-computes SYS_gettid here) so reportActualWorkDuration drives the scheduler for the thread that actually submits the work. The session is not Send, so it is created, used, and dropped here and never crosses a thread boundary.
            #[cfg(target_os = "android")]
            let hint_session = platform_android::AdpfSession::new(16_666_667, None);
            let idle_sweep_after = renderer.idle_sweep_after();
            loop {
                // One sweep per idle stretch, then park on a plain `recv` — a repeating timer would wake this
                // thread forever on a screen nobody is looking at, which is the opposite of the point.
                let msg = match idle_sweep_after {
                    Some(after) => match rx.recv_timeout(after) {
                        Ok(msg) => msg,
                        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
                            renderer.sweep_idle_caches();
                            match rx.recv() {
                                Ok(msg) => msg,
                                Err(_) => break,
                            }
                        }
                        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
                    },
                    None => match rx.recv() {
                        Ok(msg) => msg,
                        Err(_) => break,
                    },
                };
                // Drop stale frames to stay responsive, but never skip one that resizes the surface: the wgpu surface is reconfigured inside begin_frame, so a dropped resize frame leaves it at the old size and the window shows clipped content or empty margins until the next accepted frame.
                let size_changed = msg.width != current_width || msg.height != current_height;
                if !size_changed && msg.timestamp.elapsed() > FRAME_BUDGET {
                    let _ = ret_tx.send(msg.commands);
                    continue;
                }
                #[cfg(target_os = "android")]
                let frame_start = std::time::Instant::now();
                // begin_frame reconfigures the swapchain and recreates size-dependent textures; a wgpu fatal
                // error there (e.g. a lost device after a compositor resize storm) is a panic, not an `Err`, so
                // catch it as render_frame does below and drop the frame instead of unwinding into an abort.
                let began = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                    renderer.begin_frame(msg.width, msg.height, msg.scale_factor, msg.generation)
                }));
                if !matches!(began, Ok(Ok(()))) {
                    let _ = ret_tx.send(msg.commands);
                    continue;
                }
                current_width = msg.width;
                current_height = msg.height;
                // A wgpu validation error (e.g. a transient scissor/surface-size mismatch while a compositor
                // resizes a just-opened window) is fatal by default and would abort the whole process from
                // this render thread. Catch it and drop the frame so the app survives and recovers on the
                // next, correctly-sized frame.
                //
                // Recovery needs `panic=unwind`, which the consuming binary's profile decides: an app built with `panic="abort"` still goes down on a driver-level bug here.
                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                    let commands: &[renderer_core::DrawCommand] =
                        if scales_itself || msg.scale_factor == 1.0 {
                            &msg.commands
                        } else {
                            scale_scratch.scale_into(&msg.commands, msg.scale_factor)
                        };
                    renderer.render_frame(commands, msg.clear)
                }));
                #[cfg(target_os = "android")]
                if let Some(session) = &hint_session {
                    let duration_ns = frame_start.elapsed().as_nanos() as i64;
                    session.report(duration_ns);
                }
                // Recycle the buffer for the UI thread to refill; a send failure (UI gone) just drops it.
                let _ = ret_tx.send(msg.commands);
            }
            // hint_session drops here (closeSession) on this render thread before it exits.
            // Return the renderer so on_suspend can reclaim it and keep warm caches across resume.
            renderer
        })
        .expect("failed to spawn render thread");
    (tx, ret_rx, join)
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
    use std::time::{Duration, Instant};

    use geometry_core::Rect;
    use renderer_core::{DrawCommand, RectStyle, RendererError};

    use super::*;

    /// Stands in for a real backend so the pipeline itself can be tested: what it was asked to draw, at what
    /// size, and how many frames actually reached it.
    struct StubBackend {
        scales_itself: bool,
        rendered: Arc<AtomicU32>,
        report: std::sync::mpsc::Sender<Drawn>,
        size: (u32, u32),
        bound: bool,
        bound_before_first_frame: Arc<AtomicBool>,
        idle_sweep_after: Option<Duration>,
        sweeps: Arc<AtomicU32>,
    }

    impl RenderBackend for StubBackend {
        fn applies_scale_factor(&self) -> bool {
            self.scales_itself
        }

        fn bind_to_render_thread(&mut self) {
            self.bound = true;
        }

        fn idle_sweep_after(&self) -> Option<Duration> {
            self.idle_sweep_after
        }

        fn sweep_idle_caches(&mut self) {
            self.sweeps.fetch_add(1, Ordering::SeqCst);
        }

        fn begin_frame(
            &mut self,
            width: u32,
            height: u32,
            _scale_factor: f32,
            _generation: u64,
        ) -> Result<(), RendererError> {
            if self.rendered.load(Ordering::SeqCst) == 0 {
                self.bound_before_first_frame
                    .store(self.bound, Ordering::SeqCst);
            }
            self.size = (width, height);
            Ok(())
        }

        fn render_frame(
            &mut self,
            commands: &[DrawCommand],
            _clear: Option<renderer_core::Color>,
        ) -> Result<(), RendererError> {
            self.rendered.fetch_add(1, Ordering::SeqCst);
            let _ = self
                .report
                .send((self.size.0, self.size.1, commands.to_vec()));
            Ok(())
        }
    }

    fn rect(x: f32) -> DrawCommand {
        DrawCommand::Rect {
            rect: Rect::new(x, 0.0, 10.0, 10.0),
            style: Arc::new(RectStyle::default()),
        }
    }

    fn frame(width: u32, height: u32, scale_factor: f32, age: Duration) -> FrameMsg {
        FrameMsg {
            width,
            height,
            scale_factor,
            generation: 0,
            commands: vec![rect(20.0)],
            clear: None,
            timestamp: Instant::now() - age,
        }
    }

    /// What one call to `render_frame` was handed: the surface size in force, and the commands themselves.
    type Drawn = (u32, u32, Vec<DrawCommand>);

    fn stub(
        scales_itself: bool,
    ) -> (
        StubBackend,
        Arc<AtomicU32>,
        std::sync::mpsc::Receiver<Drawn>,
    ) {
        let (backend, rendered, seen, _) = stub_watching_bind(scales_itself);
        (backend, rendered, seen)
    }

    fn stub_watching_bind(
        scales_itself: bool,
    ) -> (
        StubBackend,
        Arc<AtomicU32>,
        std::sync::mpsc::Receiver<Drawn>,
        Arc<AtomicBool>,
    ) {
        let rendered = Arc::new(AtomicU32::new(0));
        let bound_before_first_frame = Arc::new(AtomicBool::new(false));
        let (report, seen) = std::sync::mpsc::channel();
        (
            StubBackend {
                scales_itself,
                rendered: Arc::clone(&rendered),
                report,
                size: (0, 0),
                bound: false,
                bound_before_first_frame: Arc::clone(&bound_before_first_frame),
                idle_sweep_after: None,
                sweeps: Arc::new(AtomicU32::new(0)),
            },
            rendered,
            seen,
            bound_before_first_frame,
        )
    }

    fn x_of(command: &DrawCommand) -> f32 {
        match command {
            DrawCommand::Rect { rect, .. } => rect.x,
            other => panic!("unexpected command: {other:?}"),
        }
    }

    #[test]
    fn a_backend_that_does_not_scale_is_handed_scaled_commands() {
        let (backend, _rendered, seen) = stub(false);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(100, 50, 2.0, Duration::ZERO)).unwrap();
        let (_, _, commands) = seen.recv_timeout(Duration::from_secs(5)).unwrap();
        assert_eq!(
            x_of(&commands[0]),
            40.0,
            "20px at scale 2 is 40 physical px"
        );

        drop(tx);
        join.join().unwrap();
    }

    // Hardware folds the scale into its shader transform, so pre-scaling would apply it twice.
    #[test]
    fn a_backend_that_scales_itself_is_handed_logical_commands() {
        let (backend, _rendered, seen) = stub(true);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(100, 50, 2.0, Duration::ZERO)).unwrap();
        let (_, _, commands) = seen.recv_timeout(Duration::from_secs(5)).unwrap();
        assert_eq!(
            x_of(&commands[0]),
            20.0,
            "left in logical px for the shader"
        );

        drop(tx);
        join.join().unwrap();
    }

    #[test]
    fn a_stale_frame_is_dropped_and_its_buffer_recycled() {
        let (backend, rendered, seen) = stub(false);
        let (tx, ret_rx, join) = spawn_render_thread(backend);

        // Establish the size first: the thread starts at 0×0, so any first frame counts as a resize and is
        // drawn however old it is — which is what makes a window show something at all.
        tx.send(frame(100, 50, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();
        let _ = ret_rx.recv_timeout(Duration::from_secs(5)).unwrap();

        tx.send(frame(100, 50, 1.0, Duration::from_millis(500)))
            .unwrap();
        let recycled = ret_rx.recv_timeout(Duration::from_secs(5)).unwrap();
        assert_eq!(recycled.len(), 1, "the buffer comes back for refilling");
        assert_eq!(
            rendered.load(Ordering::SeqCst),
            1,
            "a frame older than the budget must not be drawn"
        );

        drop(tx);
        join.join().unwrap();
    }

    // The exception that makes the rule safe: skipping a resize would leave the surface at the old size, so
    // the window shows clipped content until some later frame happens to be accepted.
    #[test]
    fn a_stale_frame_that_resizes_is_drawn_anyway() {
        let (backend, rendered, seen) = stub(false);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(100, 50, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();
        tx.send(frame(640, 480, 1.0, Duration::from_millis(500)))
            .unwrap();
        let (w, h, _) = seen.recv_timeout(Duration::from_secs(5)).unwrap();

        assert_eq!((w, h), (640, 480));
        assert_eq!(rendered.load(Ordering::SeqCst), 2);

        drop(tx);
        join.join().unwrap();
    }

    // Getting this order wrong is not a subtle bug: the software rasteriser reached its first string with a
    // shaper that had been handed no fonts, which on Android aborts the process outright.
    #[test]
    fn the_backend_is_bound_to_the_thread_before_the_first_frame() {
        let (backend, _rendered, seen, bound_first) = stub_watching_bind(false);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(100, 50, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();
        assert!(
            bound_first.load(Ordering::SeqCst),
            "bind_to_render_thread must run before the first begin_frame"
        );

        drop(tx);
        join.join().unwrap();
    }

    // The caches belong to this thread, so nothing on the UI thread can evict from them.
    #[test]
    fn an_idle_render_thread_sweeps_its_own_caches_once() {
        let (mut backend, _rendered, seen, _) = stub_watching_bind(false);
        backend.idle_sweep_after = Some(Duration::from_millis(30));
        let sweeps = Arc::clone(&backend.sweeps);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(100, 50, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();
        assert_eq!(sweeps.load(Ordering::SeqCst), 0, "not while frames arrive");

        std::thread::sleep(Duration::from_millis(300));
        assert_eq!(
            sweeps.load(Ordering::SeqCst),
            1,
            "one sweep per idle stretch, not a repeating timer"
        );

        // A frame after the idle stretch still gets drawn: the thread parked, it did not exit.
        tx.send(frame(100, 50, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();

        drop(tx);
        join.join().unwrap();
    }

    // What `on_suspend` relies on to keep the hardware device, pipelines and caches warm across a resume.
    #[test]
    fn joining_hands_the_renderer_back() {
        let (backend, _rendered, seen) = stub(false);
        let (tx, _ret_rx, join) = spawn_render_thread(backend);

        tx.send(frame(320, 240, 1.0, Duration::ZERO)).unwrap();
        seen.recv_timeout(Duration::from_secs(5)).unwrap();
        drop(tx);

        let recovered = join.join().expect("render thread panicked");
        assert_eq!(recovered.size, (320, 240), "state survived the join");
    }
}