Skip to main content

native_wgpu_host/
native_wgpu_host.rs

1use std::error::Error;
2
3use operad::platform::PixelSize;
4use operad::{
5    layout, process_document_frame, root_style, AccessibilityMeta, AccessibilityRole,
6    ApproxTextMeasurer, ColorRgba, HostDocumentFrameOutput, HostDocumentFrameRequest,
7    HostFrameOutput, HostInteractionState, InputBehavior, RenderTarget, StrokeStyle, TextStyle,
8    UiDocument, UiNode, UiSize, UiVisual,
9};
10
11#[cfg(all(feature = "accesskit-winit", feature = "native-window"))]
12use operad::{AccessKitTreeOptions, AccessKitWinitAdapter};
13
14#[cfg(feature = "wgpu")]
15use operad::{EmptyResourceResolver, RendererAdapter, WgpuRenderer};
16
17#[cfg(all(feature = "wgpu", feature = "native-window"))]
18use {
19    operad::WgpuSurfaceRenderer,
20    std::{sync::Arc, time::Duration},
21    winit::{
22        application::ApplicationHandler,
23        dpi::PhysicalSize,
24        event::WindowEvent,
25        event_loop::{ActiveEventLoop, EventLoop},
26        window::{Window, WindowId},
27    },
28};
29
30fn main() -> Result<(), Box<dyn Error>> {
31    #[cfg(all(feature = "wgpu", feature = "native-window"))]
32    if std::env::var_os("OPERAD_RUN_WGPU_EXAMPLE_WINDOW").is_some() {
33        return run_windowed_wgpu_example();
34    }
35
36    let viewport = UiSize::new(640.0, 360.0);
37    let frame = sample_frame(viewport, RenderTarget::offscreen(PixelSize::new(640, 360)))?;
38
39    println!(
40        "native_wgpu_host: {} paint items, {} accessibility nodes",
41        frame.render_request.paint.items.len(),
42        frame.accessibility_tree.nodes.len()
43    );
44
45    #[cfg(feature = "wgpu")]
46    if std::env::var_os("OPERAD_RUN_WGPU_EXAMPLE").is_some() {
47        let mut renderer = WgpuRenderer::new();
48        let output = renderer.render_frame(frame.render_request, &EmptyResourceResolver)?;
49        println!(
50            "native_wgpu_host: rendered {} items into {:?}",
51            output.painted_items, output.target
52        );
53    }
54
55    Ok(())
56}
57
58fn sample_frame(
59    viewport: UiSize,
60    target: RenderTarget,
61) -> Result<HostDocumentFrameOutput, Box<dyn Error>> {
62    let mut document = build_document();
63    let mut measurer = ApproxTextMeasurer;
64    let host_output = HostFrameOutput::new(HostInteractionState::default());
65    Ok(process_document_frame(
66        &mut document,
67        &mut measurer,
68        HostDocumentFrameRequest::new(viewport, target, host_output),
69    )?)
70}
71
72#[cfg(all(feature = "wgpu", feature = "native-window"))]
73fn run_windowed_wgpu_example() -> Result<(), Box<dyn Error>> {
74    let event_loop = EventLoop::new()?;
75    let mut app = NativeWindowApp::new(window_frame_limit()?);
76    event_loop.run_app(&mut app)?;
77    if let Some(error) = app.error {
78        Err(error.into())
79    } else {
80        if app.presented_frames > 0 {
81            println!(
82                "native_wgpu_host: native surface presented {} frame(s), p95 render {:?}",
83                app.presented_frames,
84                percentile_duration(&app.render_samples, 95.0).unwrap_or_default()
85            );
86        }
87        Ok(())
88    }
89}
90
91#[cfg(all(feature = "wgpu", feature = "native-window"))]
92struct NativeWindowApp {
93    window: Option<Arc<Window>>,
94    window_id: Option<WindowId>,
95    renderer: Option<WgpuSurfaceRenderer<'static>>,
96    #[cfg(feature = "accesskit-winit")]
97    accesskit: Option<AccessKitWinitAdapter>,
98    error: Option<String>,
99    frame_limit: Option<usize>,
100    presented_frames: usize,
101    render_samples: Vec<Duration>,
102}
103
104#[cfg(all(feature = "wgpu", feature = "native-window"))]
105impl NativeWindowApp {
106    fn new(frame_limit: Option<usize>) -> Self {
107        Self {
108            window: None,
109            window_id: None,
110            renderer: None,
111            #[cfg(feature = "accesskit-winit")]
112            accesskit: None,
113            error: None,
114            frame_limit,
115            presented_frames: 0,
116            render_samples: Vec::new(),
117        }
118    }
119
120    fn init_window(&mut self, event_loop: &ActiveEventLoop) -> Result<(), Box<dyn Error>> {
121        let window = Arc::new(
122            event_loop.create_window(
123                Window::default_attributes()
124                    .with_title("Operad native WGPU host")
125                    .with_inner_size(PhysicalSize::new(640, 360))
126                    .with_visible(false),
127            )?,
128        );
129        let size = nonzero_window_size(window.inner_size());
130
131        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
132        let surface = instance.create_surface(window.clone())?;
133        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
134            compatible_surface: Some(&surface),
135            power_preference: wgpu::PowerPreference::default(),
136            force_fallback_adapter: false,
137        }))?;
138        let (device, queue) =
139            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
140                label: Some("native-wgpu-host-device"),
141                required_features: wgpu::Features::empty(),
142                required_limits: wgpu::Limits::default(),
143                experimental_features: wgpu::ExperimentalFeatures::disabled(),
144                memory_hints: wgpu::MemoryHints::Performance,
145                trace: wgpu::Trace::Off,
146            }))?;
147        let surface_config = surface
148            .get_default_config(&adapter, size.width, size.height)
149            .ok_or("adapter does not support the native window surface")?;
150
151        self.window_id = Some(window.id());
152        self.renderer = Some(WgpuSurfaceRenderer::new(
153            surface,
154            device,
155            queue,
156            surface_config,
157        )?);
158        #[cfg(feature = "accesskit-winit")]
159        {
160            let viewport = UiSize::new(size.width as f32, size.height as f32);
161            let frame = sample_frame(viewport, RenderTarget::window("native-wgpu-host", viewport))?;
162            self.accesskit = Some(AccessKitWinitAdapter::new(
163                event_loop,
164                &window,
165                frame.accessibility_tree,
166                None,
167                AccessKitTreeOptions::default(),
168            ));
169        }
170        window.set_visible(true);
171        self.window = Some(window);
172        Ok(())
173    }
174
175    fn render(&mut self) -> Result<bool, Box<dyn Error>> {
176        let Some(window) = self.window.as_ref() else {
177            return Ok(false);
178        };
179        let Some(renderer) = self.renderer.as_mut() else {
180            return Ok(false);
181        };
182
183        let size = window.inner_size();
184        if size.width == 0 || size.height == 0 {
185            return Ok(false);
186        }
187        let viewport = UiSize::new(size.width as f32, size.height as f32);
188        let frame = sample_frame(viewport, RenderTarget::window("native-wgpu-host", viewport))?;
189        #[cfg(feature = "accesskit-winit")]
190        if let Some(accesskit) = self.accesskit.as_mut() {
191            accesskit.publish_tree(frame.accessibility_tree.clone(), None);
192        }
193        let output = renderer.render_frame(frame.render_request, &EmptyResourceResolver)?;
194        if output.snapshot.is_some() {
195            return Err("native surface smoke must present without snapshot readback".into());
196        }
197        if let Some(duration) = output.timings.duration("render") {
198            self.render_samples.push(duration);
199        }
200        self.presented_frames += 1;
201        Ok(self
202            .frame_limit
203            .is_some_and(|frame_limit| self.presented_frames >= frame_limit))
204    }
205
206    fn fail_and_exit(&mut self, event_loop: &ActiveEventLoop, error: impl ToString) {
207        self.error = Some(error.to_string());
208        event_loop.exit();
209    }
210}
211
212#[cfg(all(feature = "wgpu", feature = "native-window"))]
213impl ApplicationHandler for NativeWindowApp {
214    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
215        if self.window.is_some() {
216            return;
217        }
218        if let Err(error) = self.init_window(event_loop) {
219            self.fail_and_exit(event_loop, error);
220            return;
221        }
222        if let Some(window) = self.window.as_ref() {
223            window.request_redraw();
224        }
225    }
226
227    fn window_event(
228        &mut self,
229        event_loop: &ActiveEventLoop,
230        window_id: WindowId,
231        event: WindowEvent,
232    ) {
233        if Some(window_id) != self.window_id {
234            return;
235        }
236
237        #[cfg(feature = "accesskit-winit")]
238        if let (Some(window), Some(accesskit)) = (self.window.as_ref(), self.accesskit.as_mut()) {
239            accesskit.process_event(window, &event);
240        }
241
242        match event {
243            WindowEvent::CloseRequested | WindowEvent::Destroyed => event_loop.exit(),
244            WindowEvent::Resized(size) => {
245                if size.width > 0 && size.height > 0 {
246                    if let Some(window) = self.window.as_ref() {
247                        window.request_redraw();
248                    }
249                }
250            }
251            WindowEvent::RedrawRequested => match self.render() {
252                Ok(true) => event_loop.exit(),
253                Ok(false) => {
254                    if self.frame_limit.is_some() {
255                        if let Some(window) = self.window.as_ref() {
256                            window.request_redraw();
257                        }
258                    }
259                }
260                Err(error) => self.fail_and_exit(event_loop, error),
261            },
262            _ => {}
263        }
264    }
265}
266
267#[cfg(all(feature = "wgpu", feature = "native-window"))]
268fn nonzero_window_size(size: PhysicalSize<u32>) -> PhysicalSize<u32> {
269    PhysicalSize::new(size.width.max(1), size.height.max(1))
270}
271
272#[cfg(all(feature = "wgpu", feature = "native-window"))]
273fn window_frame_limit() -> Result<Option<usize>, Box<dyn Error>> {
274    let Some(value) = std::env::var_os("OPERAD_WGPU_EXAMPLE_WINDOW_FRAMES") else {
275        return Ok(None);
276    };
277    let frames = value
278        .to_string_lossy()
279        .parse::<usize>()
280        .map_err(|error| format!("invalid OPERAD_WGPU_EXAMPLE_WINDOW_FRAMES: {error}"))?;
281    if frames == 0 {
282        return Err("OPERAD_WGPU_EXAMPLE_WINDOW_FRAMES must be greater than zero".into());
283    }
284    Ok(Some(frames))
285}
286
287#[cfg(all(feature = "wgpu", feature = "native-window"))]
288fn percentile_duration(samples: &[Duration], percentile: f64) -> Option<Duration> {
289    if samples.is_empty() {
290        return None;
291    }
292    let mut sorted = samples.to_vec();
293    sorted.sort_unstable();
294    let clamped = percentile.clamp(0.0, 100.0);
295    let index = ((clamped / 100.0) * (sorted.len().saturating_sub(1) as f64)).ceil() as usize;
296    sorted.get(index).copied()
297}
298
299fn build_document() -> UiDocument {
300    let mut document = UiDocument::new(root_style(640.0, 360.0));
301    let panel = document.add_child(
302        document.root,
303        UiNode::container(
304            "native.panel",
305            layout::node_style(layout::with_margin_all(
306                layout::with_size(layout::column(), layout::px(280.0), layout::px(340.0)),
307                24.0,
308            )),
309        )
310        .with_visual(UiVisual::panel(
311            ColorRgba::new(24, 29, 36, 255),
312            Some(StrokeStyle::new(ColorRgba::new(91, 110, 132, 255), 1.0)),
313            6.0,
314        )),
315    );
316
317    document.add_child(
318        panel,
319        UiNode::text(
320            "native.title",
321            "Operad native WGPU host",
322            TextStyle {
323                font_size: 18.0,
324                line_height: 24.0,
325                color: ColorRgba::WHITE,
326                ..TextStyle::default()
327            },
328            layout::size(layout::percent(1.0), layout::px(32.0)),
329        ),
330    );
331
332    for (index, label) in ["Play", "Select"].into_iter().enumerate() {
333        document.add_child(
334            panel,
335            UiNode::text(
336                format!("native.button.{index}"),
337                label,
338                TextStyle::default(),
339                layout::size(layout::percent(1.0), layout::px(34.0)),
340            )
341            .with_input(InputBehavior::BUTTON)
342            .with_visual(UiVisual::panel(
343                ColorRgba::new(42, 51, 63, 255),
344                Some(StrokeStyle::new(ColorRgba::new(112, 135, 162, 255), 1.0)),
345                4.0,
346            ))
347            .with_accessibility(
348                AccessibilityMeta::new(AccessibilityRole::Button)
349                    .label(label)
350                    .focusable(),
351            ),
352        );
353    }
354
355    let text_input = document.add_child(
356        panel,
357        UiNode::container(
358            "native.text_input",
359            layout::node_style(layout::with_size(
360                layout::row(),
361                layout::percent(1.0),
362                layout::px(34.0),
363            )),
364        )
365        .with_input(InputBehavior::BUTTON)
366        .with_visual(UiVisual::panel(
367            ColorRgba::new(18, 22, 28, 255),
368            Some(StrokeStyle::new(ColorRgba::new(72, 84, 104, 255), 1.0)),
369            4.0,
370        ))
371        .with_accessibility(
372            AccessibilityMeta::new(AccessibilityRole::TextBox)
373                .label("Filter")
374                .value("filter clips")
375                .focusable(),
376        ),
377    );
378    document.add_child(
379        text_input,
380        UiNode::text(
381            "native.text_input.value",
382            "filter clips",
383            TextStyle::default(),
384            layout::size(layout::percent(1.0), layout::px(30.0)),
385        ),
386    );
387
388    let menu = document.add_child(
389        panel,
390        UiNode::container(
391            "native.popup.menu",
392            layout::node_style(layout::with_size(
393                layout::column(),
394                layout::percent(1.0),
395                layout::px(68.0),
396            )),
397        )
398        .with_visual(UiVisual::panel(
399            ColorRgba::new(31, 38, 48, 255),
400            Some(StrokeStyle::new(ColorRgba::new(112, 135, 162, 255), 1.0)),
401            4.0,
402        ))
403        .with_accessibility(AccessibilityMeta::new(AccessibilityRole::Menu).label("Mode menu")),
404    );
405    for (index, label) in ["Auto", "Manual"].into_iter().enumerate() {
406        document.add_child(
407            menu,
408            UiNode::text(
409                format!("native.popup.item.{index}"),
410                label,
411                TextStyle::default(),
412                layout::size(layout::percent(1.0), layout::px(30.0)),
413            )
414            .with_input(InputBehavior::BUTTON)
415            .with_accessibility(
416                AccessibilityMeta::new(AccessibilityRole::MenuItem)
417                    .label(label)
418                    .focusable(),
419            ),
420        );
421    }
422
423    document.add_child(
424        panel,
425        UiNode::text(
426            "native.drag.handle",
427            "Drag handle",
428            TextStyle::default(),
429            layout::size(layout::percent(1.0), layout::px(28.0)),
430        )
431        .with_input(InputBehavior::BUTTON)
432        .with_accessibility(
433            AccessibilityMeta::new(AccessibilityRole::Slider)
434                .label("Drag handle")
435                .focusable(),
436        ),
437    );
438
439    document.add_child(
440        panel,
441        UiNode::canvas(
442            "native.canvas.viewport",
443            "native.canvas.viewport",
444            layout::size(layout::percent(1.0), layout::px(52.0)),
445        )
446        .with_visual(UiVisual::panel(
447            ColorRgba::new(18, 22, 28, 255),
448            Some(StrokeStyle::new(ColorRgba::new(72, 84, 104, 255), 1.0)),
449            4.0,
450        ))
451        .with_accessibility(
452            AccessibilityMeta::new(AccessibilityRole::Group)
453                .label("Canvas viewport")
454                .focusable(),
455        ),
456    );
457
458    document
459}