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::{Color, 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/// Renders an element tree over a caller-supplied base color at a scale
91/// factor — the entry point for offline frame samplers (`fenestra-motion`)
92/// and any render that needs real alpha: `Color::TRANSPARENT` keeps empty
93/// canvas at alpha 0 (note vello output is premultiplied; un-premultiply
94/// before writing straight-alpha formats).
95///
96/// `size` is logical px; the texture is `size × scale` (clamped to the
97/// device limit). At `scale == 1.0` this is the same two-pass pipeline as
98/// [`render_element`] (frosted glass gets its backdrop pass); other scales
99/// render single-pass — glass falls back to its translucent tint, exactly
100/// like the live window — which is fine for the cheap-preview use these
101/// scaled renders exist for.
102///
103/// Caller-provided [`Fonts`] and [`FrameState`] keep this path lock-free up
104/// to the shared GPU: parallel callers build layouts concurrently and
105/// serialize only on the device mutex.
106///
107/// # Errors
108/// [`ShellError`] when no compute-capable GPU adapter exists or the render
109/// fails.
110pub fn render_element_over<Msg>(
111    el: Element<Msg>,
112    theme: &Theme,
113    size: (u32, u32),
114    scale: f64,
115    bg: Color,
116    fonts: &mut Fonts,
117    state: &mut FrameState,
118) -> Result<RgbaImage, ShellError> {
119    let scale = if scale.is_finite() && scale > 0.0 {
120        scale
121    } else {
122        1.0
123    };
124    #[expect(
125        clippy::cast_possible_truncation,
126        clippy::cast_sign_loss,
127        reason = "physical sizes are clamped to the device range right after"
128    )]
129    let physical = (
130        (f64::from(size.0) * scale).round() as u32,
131        (f64::from(size.1) * scale).round() as u32,
132    );
133    let (pw, ph) = with_headless(|h| h.clamp_size(physical.0, physical.1))?;
134    #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
135    let logical = (size.0 as f32, size.1 as f32);
136    let frame = build_frame(&el, theme, fonts, state, logical, scale);
137    if (scale - 1.0).abs() < f64::EPSILON {
138        return with_headless(|headless| headless.render_plan(&frame, fonts, state, pw, ph, bg))?;
139    }
140    let logical_scene = frame.paint(fonts, state);
141    let mut scene = vello::Scene::new();
142    scene.append(&logical_scene, Some(vello::kurbo::Affine::scale(scale)));
143    with_headless(|headless| headless.render(&scene, pw, ph, bg))?
144}
145
146/// Like [`render_element`], but with caller-provided retained state, so
147/// tests can render scrolled (and later focused/hovered) configurations.
148/// The requested size is clamped like [`render_element`]'s.
149///
150/// # Panics
151/// If no compute-capable GPU adapter exists or rendering fails.
152pub fn render_element_with_state<Msg>(
153    el: Element<Msg>,
154    theme: &Theme,
155    size: (u32, u32),
156    state: &mut FrameState,
157) -> RgbaImage {
158    // Clamp before layout so the frame and the texture agree on the size.
159    let size =
160        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
161    // Hold the font lock across both passes, then nest the headless lock for the
162    // render (fonts → headless ordering, matching every other render site).
163    with_fonts(|fonts| {
164        #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
165        let frame = build_frame(
166            &el,
167            theme,
168            fonts,
169            state,
170            (size.0 as f32, size.1 as f32),
171            1.0,
172        );
173        with_headless(|headless| {
174            headless.render_plan(&frame, fonts, state, size.0, size.1, theme.bg)
175        })
176        .expect("headless renderer unavailable")
177    })
178    .expect("headless render failed")
179}