Skip to main content

native_wgpu_host/
native_wgpu_host.rs

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