Skip to main content

fenestra_shell/
element_render.rs

1//! Headless element rendering: the API agents use to see what they build.
2
3use std::sync::{Mutex, OnceLock};
4
5use fenestra_core::{Element, Fonts, FrameState, Theme, build_frame};
6use image::RgbaImage;
7
8use crate::{Headless, ShellError};
9
10static SHARED: OnceLock<Mutex<Headless>> = OnceLock::new();
11static FONTS: OnceLock<Mutex<Fonts>> = OnceLock::new();
12
13/// Runs `f` with the process-wide embedded-only font system used by all
14/// headless rendering.
15pub fn with_fonts<R>(f: impl FnOnce(&mut Fonts) -> R) -> R {
16    let fonts = FONTS.get_or_init(|| Mutex::new(Fonts::embedded()));
17    let mut guard = fonts
18        .lock()
19        .unwrap_or_else(std::sync::PoisonError::into_inner);
20    f(&mut guard)
21}
22
23/// Runs `f` with a process-wide shared [`Headless`] renderer. Creating a
24/// renderer compiles vello's shaders, so tests share one.
25pub fn with_headless<R>(f: impl FnOnce(&mut Headless) -> R) -> Result<R, ShellError> {
26    // Initialization can fail (no adapter); retry on each call until it
27    // succeeds rather than caching the failure.
28    if SHARED.get().is_none() {
29        let headless = Headless::new()?;
30        let _ = SHARED.set(Mutex::new(headless));
31    }
32    let mutex = SHARED.get().ok_or(ShellError::NoDevice)?;
33    let mut guard = mutex
34        .lock()
35        .unwrap_or_else(std::sync::PoisonError::into_inner);
36    Ok(f(&mut guard))
37}
38
39/// Renders an element tree headlessly at scale factor 1.0 over the theme
40/// background, using only the embedded fonts for determinism. This is
41/// fenestra's product thesis: agents render what they build and look at it.
42///
43/// The requested size is clamped to the device-supported range (at least
44/// 1x1, at most the maximum texture dimension, typically 8192); check the
45/// returned image's dimensions when the input may be out of range.
46///
47/// # Panics
48/// If no compute-capable GPU adapter exists or rendering fails.
49pub fn render_element<Msg>(el: Element<Msg>, theme: &Theme, size: (u32, u32)) -> RgbaImage {
50    let mut state = FrameState::new();
51    state.reduced_motion = true;
52    render_element_with_state(el, theme, size, &mut state)
53}
54
55/// Like [`render_element`], but with caller-provided [`Fonts`], so design
56/// languages can register Display/Serif faces (`Fonts::register`) and
57/// render through them. The requested size is clamped like
58/// [`render_element`]'s.
59///
60/// # Panics
61/// If no compute-capable GPU adapter exists or rendering fails.
62pub fn render_element_with<Msg>(
63    el: Element<Msg>,
64    theme: &Theme,
65    size: (u32, u32),
66    fonts: &mut Fonts,
67) -> RgbaImage {
68    let size =
69        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
70    let mut state = FrameState::new();
71    state.reduced_motion = true;
72    #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
73    let frame = build_frame(
74        &el,
75        theme,
76        fonts,
77        &mut state,
78        (size.0 as f32, size.1 as f32),
79        1.0,
80    );
81    // Route through the two-pass planner so frosted glass shows real backdrop
82    // blur; frames with no glass / filter fast-path to a single pass.
83    with_headless(|headless| {
84        headless.render_plan(&frame, fonts, &mut state, size.0, size.1, theme.bg)
85    })
86    .expect("headless renderer unavailable")
87    .expect("headless render failed")
88}
89
90/// Like [`render_element`], but with caller-provided retained state, so
91/// tests can render scrolled (and later focused/hovered) configurations.
92/// The requested size is clamped like [`render_element`]'s.
93///
94/// # Panics
95/// If no compute-capable GPU adapter exists or rendering fails.
96pub fn render_element_with_state<Msg>(
97    el: Element<Msg>,
98    theme: &Theme,
99    size: (u32, u32),
100    state: &mut FrameState,
101) -> RgbaImage {
102    // Clamp before layout so the frame and the texture agree on the size.
103    let size =
104        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
105    // Hold the font lock across both passes, then nest the headless lock for the
106    // render (fonts → headless ordering, matching every other render site).
107    with_fonts(|fonts| {
108        #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
109        let frame = build_frame(
110            &el,
111            theme,
112            fonts,
113            state,
114            (size.0 as f32, size.1 as f32),
115            1.0,
116        );
117        with_headless(|headless| {
118            headless.render_plan(&frame, fonts, state, size.0, size.1, theme.bg)
119        })
120        .expect("headless renderer unavailable")
121    })
122    .expect("headless render failed")
123}