Skip to main content

fenestra_shell/
embed.rs

1//! Embedded mode: run a fenestra [`App`] inside a wgpu app *you* own —
2//! your event loop, your device, your surface, your frame pacing.
3//! fenestra renders to an internal texture and composites onto any
4//! target view with premultiplied-alpha blending, so your scene shows
5//! through wherever the UI doesn't paint (set a transparent clear).
6//!
7//! ```ignore
8//! // setup (once): your device, your surface format
9//! let mut ui = Embedded::new(MyApp::default(), Theme::dark(), &device, surface_format);
10//! ui.set_clear(Color::TRANSPARENT);
11//!
12//! // per winit event:
13//! let response = ui.handle_window_event(&window, &event);
14//! if response.repaint { window.request_redraw(); }
15//! if response.consumed { return; } // fenestra took it
16//!
17//! // per frame, after your own passes:
18//! ui.render(&device, &queue, &surface_view, (w, h), window.scale_factor());
19//! ```
20//!
21//! The batteries-included runner remains the easy path; this is the
22//! narrow waist for engines and existing apps. Secondary windows
23//! ([`App::windows`]) and IME candidate positioning are runner-only.
24
25use std::sync::{Arc, Mutex, PoisonError};
26use std::time::Instant;
27
28use fenestra_core::{
29    App, Element, Fonts, Frame, FrameState, InputEvent, Proxy, Theme, build_frame, dispatch,
30};
31use kurbo::Point;
32use vello::wgpu::{self, util::TextureBlitter};
33use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
34
35use crate::window::{LINE_SCROLL_PX, map_cursor, map_key};
36
37/// What the embedded UI did with one window event.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct EventResponse {
40    /// The event targeted fenestra content (pointer over a widget,
41    /// keystroke while a widget has focus) — skip your own handling.
42    pub consumed: bool,
43    /// State changed; render again soon.
44    pub repaint: bool,
45}
46
47/// A fenestra app embedded in a caller-owned wgpu world. See the
48/// module docs for the contract.
49pub struct Embedded<A: App> {
50    app: A,
51    theme: Theme,
52    fonts: Fonts,
53    state: FrameState,
54    renderer: Renderer,
55    blitter: TextureBlitter,
56    /// Internal premultiplied-alpha target, resized lazily.
57    target: Option<(wgpu::Texture, wgpu::TextureView, u32, u32)>,
58    last: Option<(Element<A::Msg>, Frame)>,
59    pending: Arc<Mutex<Vec<A::Msg>>>,
60    cursor: Point,
61    /// Cursor icon requested by the last dispatch, applied by
62    /// [`Self::handle_window_event`].
63    cursor_icon: Option<fenestra_core::Cursor>,
64    modifiers: winit::keyboard::ModifiersState,
65    started: Instant,
66    clear: fenestra_core::Color,
67}
68
69impl<A: App> Embedded<A>
70where
71    A::Msg: Send,
72{
73    /// Builds the renderer on *your* device. `target_format` is the
74    /// format of the views you will pass to [`Self::render`] (usually
75    /// your surface format).
76    ///
77    /// # Panics
78    /// If vello's shaders fail to compile on the device.
79    pub fn new(
80        mut app: A,
81        theme: Theme,
82        device: &wgpu::Device,
83        target_format: wgpu::TextureFormat,
84    ) -> Self {
85        let renderer = Renderer::new(
86            device,
87            RendererOptions {
88                use_cpu: false,
89                antialiasing_support: AaSupport::area_only(),
90                num_init_threads: std::num::NonZeroUsize::new(1),
91                pipeline_cache: None,
92            },
93        )
94        .expect("vello renderer on caller device");
95        let blitter = wgpu::util::TextureBlitterBuilder::new(device, target_format)
96            .blend_state(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING)
97            .build();
98        let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
99        let sink = Arc::clone(&pending);
100        app.init(Proxy::new(move |msg| {
101            sink.lock()
102                .unwrap_or_else(PoisonError::into_inner)
103                .push(msg);
104        }));
105        let mut state = FrameState::new();
106        state.set_clipboard(Box::new(crate::OsClipboard::default()));
107        let clear = theme.bg;
108        Self {
109            app,
110            theme,
111            fonts: Fonts::with_system(),
112            state,
113            renderer,
114            blitter,
115            target: None,
116            last: None,
117            pending,
118            cursor: Point::ORIGIN,
119            cursor_icon: None,
120            modifiers: winit::keyboard::ModifiersState::default(),
121            started: Instant::now(),
122            clear,
123        }
124    }
125
126    /// The base color behind the UI. Defaults to the theme background;
127    /// set `Color::TRANSPARENT` to composite over your own scene.
128    pub fn set_clear(&mut self, color: fenestra_core::Color) {
129        self.clear = color;
130    }
131
132    /// Replaces the theme (e.g. a light/dark toggle driven by your app).
133    pub fn set_theme(&mut self, theme: Theme) {
134        self.theme = theme;
135    }
136
137    /// The app under the UI.
138    pub fn app(&self) -> &A {
139        &self.app
140    }
141
142    /// Mutable app access (the next [`Self::render`] rebuilds).
143    pub fn app_mut(&mut self) -> &mut A {
144        &mut self.app
145    }
146
147    /// Drains proxied messages (from [`App::init`] / threads) into the
148    /// app. Returns whether anything was applied — repaint if so.
149    pub fn pump(&mut self) -> bool {
150        let msgs =
151            std::mem::take(&mut *self.pending.lock().unwrap_or_else(PoisonError::into_inner));
152        let any = !msgs.is_empty();
153        for msg in msgs {
154            self.app.update(msg);
155        }
156        any
157    }
158
159    fn hits(&self, point: Point) -> bool {
160        self.last
161            .as_ref()
162            .is_some_and(|(_, frame)| frame.hit_chain(point).len() > 1)
163    }
164
165    /// Routes one raw input event into the UI. Prefer
166    /// [`Self::handle_window_event`] in winit apps; this is the
167    /// window-system-agnostic form (and what tests drive).
168    pub fn input(&mut self, event: InputEvent) -> EventResponse {
169        // Consumption heuristic, judged against the *current* frame:
170        // pointer events over a widget, keystrokes while focused.
171        let consumed = match &event {
172            InputEvent::PointerMove { x, y } => self.hits(Point::new(f64::from(*x), f64::from(*y))),
173            InputEvent::PointerDown
174            | InputEvent::PointerUp
175            | InputEvent::RightDown
176            | InputEvent::RightUp
177            | InputEvent::Wheel { .. } => self.hits(self.cursor),
178            InputEvent::Key(_) | InputEvent::Text(_) | InputEvent::ImePreedit { .. } => {
179                self.state.focused().is_some()
180            }
181            _ => false,
182        };
183        if let InputEvent::PointerMove { x, y } = event {
184            self.cursor = Point::new(f64::from(x), f64::from(y));
185        }
186        let Some((view, frame)) = &self.last else {
187            return EventResponse {
188                consumed: false,
189                repaint: true,
190            };
191        };
192        let result = dispatch(view, frame, &mut self.state, &mut self.fonts, event);
193        self.cursor_icon = result.cursor;
194        let had_msgs = !result.msgs.is_empty();
195        for msg in result.msgs {
196            self.app.update(msg);
197        }
198        EventResponse {
199            consumed,
200            repaint: result.redraw || had_msgs,
201        }
202    }
203
204    /// Translates and routes one winit event (cursor, buttons, wheel,
205    /// keyboard with the printable/shortcut split, IME commit/preedit,
206    /// modifiers) — the same mapping the built-in runner uses.
207    pub fn handle_window_event(
208        &mut self,
209        window: &winit::window::Window,
210        event: &winit::event::WindowEvent,
211    ) -> EventResponse {
212        use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
213        let scale = window.scale_factor();
214        match event {
215            WindowEvent::CursorMoved { position, .. } =>
216            {
217                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
218                self.input(InputEvent::PointerMove {
219                    x: (position.x / scale) as f32,
220                    y: (position.y / scale) as f32,
221                })
222            }
223            WindowEvent::CursorLeft { .. } => self.input(InputEvent::PointerLeave),
224            WindowEvent::MouseInput { state, button, .. } => {
225                let event = match (button, state) {
226                    (MouseButton::Left, ElementState::Pressed) => InputEvent::PointerDown,
227                    (MouseButton::Left, ElementState::Released) => InputEvent::PointerUp,
228                    (MouseButton::Right, ElementState::Pressed) => InputEvent::RightDown,
229                    (MouseButton::Right, ElementState::Released) => InputEvent::RightUp,
230                    _ => return EventResponse::default(),
231                };
232                let response = self.input(event);
233                if let Some(cursor) = self.cursor_icon.take() {
234                    window.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
235                }
236                response
237            }
238            WindowEvent::MouseWheel { delta, .. } => {
239                let dy = match delta {
240                    MouseScrollDelta::LineDelta(_, y) => f64::from(*y) * LINE_SCROLL_PX,
241                    MouseScrollDelta::PixelDelta(pos) => pos.y / scale,
242                };
243                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
244                self.input(InputEvent::Wheel { dy: dy as f32 })
245            }
246            WindowEvent::ModifiersChanged(mods) => {
247                self.modifiers = mods.state();
248                let m = self.modifiers;
249                self.input(InputEvent::Modifiers {
250                    shift: m.shift_key(),
251                    ctrl: m.control_key(),
252                    alt: m.alt_key(),
253                    meta: m.super_key(),
254                })
255            }
256            WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
257                let mods = self.modifiers;
258                let printable = !mods.control_key()
259                    && !mods.super_key()
260                    && event
261                        .text
262                        .as_ref()
263                        .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
264                if printable {
265                    match &event.text {
266                        Some(t) => self.input(InputEvent::Text(t.to_string())),
267                        None => EventResponse::default(),
268                    }
269                } else if let Some(input) = map_key(event, mods) {
270                    self.input(input)
271                } else {
272                    EventResponse::default()
273                }
274            }
275            WindowEvent::Ime(ime) => match ime {
276                winit::event::Ime::Preedit(text, cursor) => self.input(InputEvent::ImePreedit {
277                    text: text.clone(),
278                    cursor: *cursor,
279                }),
280                winit::event::Ime::Commit(text) => self.input(InputEvent::Text(text.clone())),
281                _ => EventResponse::default(),
282            },
283            _ => EventResponse::default(),
284        }
285    }
286
287    /// Whether the last built frame is still animating (keep rendering).
288    pub fn animating(&self) -> bool {
289        self.last.as_ref().is_some_and(|(_, f)| f.animating)
290    }
291
292    /// Builds the current frame and composites it onto `target` with
293    /// premultiplied-alpha blending. `physical` is the target size in
294    /// physical pixels; `scale` the DPI factor (logical = physical /
295    /// scale). Call after your own passes each frame.
296    ///
297    /// # Panics
298    /// If vello fails to render (device loss).
299    pub fn render(
300        &mut self,
301        device: &wgpu::Device,
302        queue: &wgpu::Queue,
303        target: &wgpu::TextureView,
304        physical: (u32, u32),
305        scale: f64,
306    ) {
307        self.pump();
308        let (pw, ph) = (physical.0.max(1), physical.1.max(1));
309        self.state.tick(self.started.elapsed().as_secs_f64());
310        let view = self.app.view();
311        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
312        let logical = (
313            (f64::from(pw) / scale) as f32,
314            (f64::from(ph) / scale) as f32,
315        );
316        let frame = build_frame(
317            &view,
318            &self.theme,
319            &mut self.fonts,
320            &mut self.state,
321            logical,
322            scale,
323        );
324        let scene: Scene = frame.paint(&mut self.fonts, &mut self.state);
325
326        if self
327            .target
328            .as_ref()
329            .is_none_or(|(_, _, w, h)| (*w, *h) != (pw, ph))
330        {
331            let texture = device.create_texture(&wgpu::TextureDescriptor {
332                label: Some("fenestra embedded target"),
333                size: wgpu::Extent3d {
334                    width: pw,
335                    height: ph,
336                    depth_or_array_layers: 1,
337                },
338                mip_level_count: 1,
339                sample_count: 1,
340                dimension: wgpu::TextureDimension::D2,
341                format: wgpu::TextureFormat::Rgba8Unorm,
342                usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
343                view_formats: &[],
344            });
345            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
346            self.target = Some((texture, view, pw, ph));
347        }
348        let (_, internal_view, ..) = self.target.as_ref().expect("just ensured");
349
350        self.renderer
351            .render_to_texture(
352                device,
353                queue,
354                &scene,
355                internal_view,
356                &RenderParams {
357                    base_color: self.clear,
358                    width: pw,
359                    height: ph,
360                    antialiasing_method: AaConfig::Area,
361                },
362            )
363            .expect("vello render");
364
365        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
366            label: Some("fenestra embedded blit"),
367        });
368        self.blitter
369            .copy(device, &mut encoder, internal_view, target);
370        queue.submit([encoder.finish()]);
371        self.last = Some((view, frame));
372    }
373
374    /// The last built frame (after [`Self::render`]) — semantic queries
375    /// and inspector dumps work on it like anywhere else.
376    pub fn frame(&self) -> Option<&Frame> {
377        self.last.as_ref().map(|(_, frame)| frame)
378    }
379
380    /// The internal premultiplied-alpha texture view from the last
381    /// [`Self::render`] — sample it in your own pipeline for custom
382    /// compositing instead of the built-in blit.
383    pub fn texture_view(&self) -> Option<&wgpu::TextureView> {
384        self.target.as_ref().map(|(_, view, ..)| view)
385    }
386}