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 retained state, so
56/// tests can render scrolled (and later focused/hovered) configurations.
57/// The requested size is clamped like [`render_element`]'s.
58///
59/// # Panics
60/// If no compute-capable GPU adapter exists or rendering fails.
61pub fn render_element_with_state<Msg>(
62    el: Element<Msg>,
63    theme: &Theme,
64    size: (u32, u32),
65    state: &mut FrameState,
66) -> RgbaImage {
67    // Clamp before layout so the frame and the texture agree on the size.
68    let size =
69        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
70    let scene = with_fonts(|fonts| {
71        #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
72        let frame = build_frame(
73            &el,
74            theme,
75            fonts,
76            state,
77            (size.0 as f32, size.1 as f32),
78            1.0,
79        );
80        frame.paint(fonts, state)
81    });
82    with_headless(|headless| headless.render(&scene, size.0, size.1, theme.bg))
83        .expect("headless renderer unavailable")
84        .expect("headless render failed")
85}