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    let scene = frame.paint(fonts, &mut state);
82    with_headless(|headless| headless.render(&scene, size.0, size.1, theme.bg))
83        .expect("headless renderer unavailable")
84        .expect("headless render failed")
85}
86
87/// Like [`render_element`], but with caller-provided retained state, so
88/// tests can render scrolled (and later focused/hovered) configurations.
89/// The requested size is clamped like [`render_element`]'s.
90///
91/// # Panics
92/// If no compute-capable GPU adapter exists or rendering fails.
93pub fn render_element_with_state<Msg>(
94    el: Element<Msg>,
95    theme: &Theme,
96    size: (u32, u32),
97    state: &mut FrameState,
98) -> RgbaImage {
99    // Clamp before layout so the frame and the texture agree on the size.
100    let size =
101        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
102    let scene = with_fonts(|fonts| {
103        #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
104        let frame = build_frame(
105            &el,
106            theme,
107            fonts,
108            state,
109            (size.0 as f32, size.1 as f32),
110            1.0,
111        );
112        frame.paint(fonts, state)
113    });
114    with_headless(|headless| headless.render(&scene, size.0, size.1, theme.bg))
115        .expect("headless renderer unavailable")
116        .expect("headless render failed")
117}