Skip to main content

aetna_winit_wgpu/
lib.rs

1//! Optional desktop host for running [`App`]s against a real `wgpu`
2//! surface in a `winit` window.
3//!
4//! Most native apps should use this crate instead of calling
5//! `aetna-wgpu` directly:
6//!
7//! ```ignore
8//! use aetna_core::prelude::*;
9//!
10//! fn main() -> Result<(), Box<dyn std::error::Error>> {
11//!     let viewport = Rect::new(0.0, 0.0, 720.0, 480.0);
12//!     aetna_winit_wgpu::run("My Aetna App", viewport, MyApp::default())
13//! }
14//! ```
15//!
16//! The host owns the event loop, window, device/queue, surface
17//! configuration, render pass boundaries, input mapping, IME forwarding,
18//! and animation redraw cadence. Your code owns the [`App`]: application
19//! state, [`App::build`], [`App::on_event`], optional hotkeys, custom
20//! shaders, and theme.
21//!
22//! [`run`] takes an [`App`] and runs an event loop that:
23//!
24//! - Calls [`App::build`] on every redraw, applying current hover/press
25//!   visuals automatically before paint.
26//! - Routes `winit` pointer events through the renderer's hit-tester
27//!   and dispatches events back via [`App::on_event`].
28//! - Routes Tab/Shift-Tab through focus traversal and Enter/Space/Escape
29//!   through keyboard events.
30//! - Copies the current Aetna text selection to the native clipboard
31//!   on Ctrl/Cmd+C.
32//! - Requests a redraw whenever interaction state changes (mouse move,
33//!   button down/up) so hover/press visuals are immediate.
34//!
35//! Use [`run_with_config`] when a simple app needs a fixed redraw
36//! cadence for external live state such as meters. Put per-frame state
37//! refresh in [`App::before_build`]. For fully custom render-loop
38//! integration, bypass this crate and call `aetna_wgpu::Runner`
39//! directly.
40
41use std::{
42    sync::Arc,
43    time::{Duration, Instant},
44};
45
46use aetna_core::widgets::text_input::{self, ClipboardKind};
47use aetna_core::{
48    App, Cursor, FrameTrigger, HostDiagnostics, KeyModifiers, Pointer, PointerButton, Rect, Sides,
49    UiEvent, UiEventKind, UiKey, clipboard,
50};
51use aetna_wgpu::{MsaaTarget, Runner};
52
53const DEFAULT_SAMPLE_COUNT: u32 = 4;
54#[cfg(not(any(target_os = "android", target_os = "ios")))]
55type PlatformClipboard = Option<arboard::Clipboard>;
56#[cfg(target_os = "android")]
57struct PlatformClipboard {
58    app: AndroidApp,
59}
60#[cfg(target_os = "ios")]
61#[derive(Default)]
62struct PlatformClipboard;
63
64use winit::application::ApplicationHandler;
65use winit::dpi::PhysicalSize;
66use winit::event::{ElementState, Force, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
67use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
68use winit::keyboard::{Key, NamedKey};
69#[cfg(target_os = "android")]
70use winit::platform::android::{EventLoopExtAndroid, WindowExtAndroid, activity::AndroidApp};
71use winit::window::{CursorIcon, Window, WindowId};
72
73/// Configuration for the optional native winit + wgpu host.
74#[derive(Clone, Copy, Debug)]
75pub struct HostConfig {
76    /// MSAA sample count used for Aetna's SDF surfaces. The default is
77    /// 4, matching the demo and validation app paths.
78    pub sample_count: u32,
79    /// Optional fixed redraw cadence for apps with external live data
80    /// sources such as audio meters. Animation-driven redraws still
81    /// come from `Runner::prepare().needs_redraw`; this is only for
82    /// host-owned clocks.
83    pub redraw_interval: Option<Duration>,
84    /// Prefer the lowest-latency wgpu present mode the surface
85    /// advertises (`Mailbox`, falling back to `Fifo`). Default is
86    /// `Fifo`, which is vsync-locked and conservative on power.
87    ///
88    /// Why this exists: with `Fifo`, every submit queues a frame for
89    /// the next vsync; if the app submits faster than the display
90    /// refresh, the compositor pulls the *oldest* queued frame at
91    /// each vsync. On Wayland/Mesa during an interactive resize this
92    /// shows up as the window content trailing the cursor in slow
93    /// motion — by the time the latest size we rendered reaches the
94    /// screen, several more compositor `configure` events have
95    /// arrived. `Mailbox` replaces the pending frame on each submit,
96    /// so the next vsync always shows the most recent render.
97    ///
98    /// Cost: with `Mailbox`, render cadence is no longer naturally
99    /// vsync-bounded — an animation that calls `request_redraw` from
100    /// `prepare.needs_redraw` will render at GPU speed. Pair this
101    /// with `redraw_interval` (or accept the cycles) if that's not
102    /// what you want.
103    pub low_latency_present: bool,
104}
105
106impl Default for HostConfig {
107    fn default() -> Self {
108        Self {
109            sample_count: DEFAULT_SAMPLE_COUNT,
110            redraw_interval: None,
111            low_latency_present: false,
112        }
113    }
114}
115
116impl HostConfig {
117    pub fn with_redraw_interval(mut self, interval: Duration) -> Self {
118        self.redraw_interval = Some(interval);
119        self
120    }
121
122    pub fn with_sample_count(mut self, sample_count: u32) -> Self {
123        self.sample_count = sample_count.max(1);
124        self
125    }
126
127    pub fn with_low_latency_present(mut self, low_latency_present: bool) -> Self {
128        self.low_latency_present = low_latency_present;
129        self
130    }
131}
132
133/// Compatibility extension point for apps that use this host crate.
134///
135/// New apps should prefer [`App::before_build`]. This trait remains for
136/// code that wants to name a winit-host-specific app type while still
137/// using the same core lifecycle, and as a place to hang wgpu-specific
138/// hooks that the backend-neutral [`App`] trait can't carry — see
139/// [`Self::gpu_setup`] and [`Self::before_paint`].
140pub trait WinitWgpuApp: App {
141    fn before_build(&mut self) {
142        App::before_build(self);
143    }
144
145    /// Called once after the host has created its `wgpu::Device` and
146    /// before the first frame is drawn. Apps that need to allocate
147    /// app-owned GPU textures (typically for use with
148    /// [`aetna_core::surface::AppTexture`] / `surface()` widgets)
149    /// initialize them here.
150    ///
151    /// Default: no-op. App authors who don't touch wgpu directly can
152    /// ignore this hook.
153    fn gpu_setup(&mut self, _device: &wgpu::Device, _queue: &wgpu::Queue) {}
154
155    /// Called each frame just before [`App::build`] runs. Apps update
156    /// their app-owned GPU textures here — typically by
157    /// `queue.write_texture(...)` of the next animation frame so the
158    /// composite the runner draws this frame samples fresh pixels.
159    ///
160    /// Default: no-op.
161    fn before_paint(&mut self, _queue: &wgpu::Queue) {}
162}
163
164struct BasicApp<A>(A);
165
166impl<A: App> App for BasicApp<A> {
167    fn before_build(&mut self) {
168        self.0.before_build();
169    }
170
171    fn build(&self, cx: &aetna_core::BuildCx) -> aetna_core::El {
172        self.0.build(cx)
173    }
174
175    fn on_event(&mut self, event: aetna_core::UiEvent) {
176        self.0.on_event(event);
177    }
178
179    fn hotkeys(&self) -> Vec<(aetna_core::KeyChord, String)> {
180        self.0.hotkeys()
181    }
182
183    fn drain_toasts(&mut self) -> Vec<aetna_core::toast::ToastSpec> {
184        self.0.drain_toasts()
185    }
186
187    fn drain_focus_requests(&mut self) -> Vec<String> {
188        self.0.drain_focus_requests()
189    }
190
191    fn drain_scroll_requests(&mut self) -> Vec<aetna_core::scroll::ScrollRequest> {
192        self.0.drain_scroll_requests()
193    }
194
195    fn drain_link_opens(&mut self) -> Vec<String> {
196        self.0.drain_link_opens()
197    }
198
199    fn shaders(&self) -> Vec<aetna_core::AppShader> {
200        self.0.shaders()
201    }
202
203    fn theme(&self) -> aetna_core::Theme {
204        self.0.theme()
205    }
206
207    fn selection(&self) -> aetna_core::Selection {
208        self.0.selection()
209    }
210}
211
212impl<A: App> WinitWgpuApp for BasicApp<A> {}
213
214/// Run a windowed app. Blocks until the user closes the window.
215///
216/// The `App` is owned by the runner; its `&mut self` is updated in
217/// response to routed events and read on every `build` call.
218pub fn run<A: App + 'static>(
219    title: &'static str,
220    viewport: Rect,
221    app: A,
222) -> Result<(), Box<dyn std::error::Error>> {
223    run_host(title, viewport, BasicApp(app), HostConfig::default())
224}
225
226/// Run a windowed app with host-specific configuration.
227///
228/// Use this when a plain [`App`] wants a host cadence
229/// (`redraw_interval`) or non-default MSAA. For fully custom
230/// render-loop integration, bypass this crate and call
231/// `aetna_wgpu::Runner` directly.
232pub fn run_with_config<A: App + 'static>(
233    title: &'static str,
234    viewport: Rect,
235    app: A,
236    config: HostConfig,
237) -> Result<(), Box<dyn std::error::Error>> {
238    run_host(title, viewport, BasicApp(app), config)
239}
240
241/// Run a plain [`App`] using a caller-created winit event loop.
242///
243/// This is primarily for platform hosts that need to configure the
244/// event loop before Aetna owns it. Android, for example, must attach
245/// the `AndroidApp` received by `android_main` before `build()`.
246pub fn run_on_event_loop<A: App + 'static>(
247    event_loop: EventLoop<()>,
248    title: &'static str,
249    viewport: Rect,
250    app: A,
251    config: HostConfig,
252) -> Result<(), Box<dyn std::error::Error>> {
253    run_host_on_event_loop(event_loop, title, viewport, BasicApp(app), config)
254}
255
256/// Run a windowed app with host-specific configuration.
257///
258/// Prefer [`run_with_config`] for new apps; [`App::before_build`] is
259/// available there as well.
260pub fn run_host_app_with_config<A: WinitWgpuApp + 'static>(
261    title: &'static str,
262    viewport: Rect,
263    app: A,
264    config: HostConfig,
265) -> Result<(), Box<dyn std::error::Error>> {
266    run_host(title, viewport, app, config)
267}
268
269/// Run a host-specific [`WinitWgpuApp`] using a caller-created winit
270/// event loop.
271pub fn run_host_app_on_event_loop<A: WinitWgpuApp + 'static>(
272    event_loop: EventLoop<()>,
273    title: &'static str,
274    viewport: Rect,
275    app: A,
276    config: HostConfig,
277) -> Result<(), Box<dyn std::error::Error>> {
278    run_host_on_event_loop(event_loop, title, viewport, app, config)
279}
280
281/// Run a windowed app with default host configuration.
282///
283/// Prefer [`run`] for new apps; [`App::before_build`] is available
284/// there as well.
285pub fn run_host_app<A: WinitWgpuApp + 'static>(
286    title: &'static str,
287    viewport: Rect,
288    app: A,
289) -> Result<(), Box<dyn std::error::Error>> {
290    run_host(title, viewport, app, HostConfig::default())
291}
292
293fn run_host<A: WinitWgpuApp + 'static>(
294    title: &'static str,
295    viewport: Rect,
296    app: A,
297    config: HostConfig,
298) -> Result<(), Box<dyn std::error::Error>> {
299    let event_loop = EventLoop::new()?;
300    run_host_on_event_loop(event_loop, title, viewport, app, config)
301}
302
303fn run_host_on_event_loop<A: WinitWgpuApp + 'static>(
304    event_loop: EventLoop<()>,
305    title: &'static str,
306    viewport: Rect,
307    app: A,
308    config: HostConfig,
309) -> Result<(), Box<dyn std::error::Error>> {
310    event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
311    #[cfg(target_os = "android")]
312    let android_app = event_loop.android_app().clone();
313    #[cfg(not(target_os = "android"))]
314    let clipboard = new_clipboard();
315    #[cfg(target_os = "android")]
316    let clipboard = new_clipboard(&android_app);
317    let mut host = Host {
318        title,
319        viewport,
320        config,
321        app,
322        #[cfg(target_os = "android")]
323        android_app,
324        gfx: None,
325        last_pointer: None,
326        modifiers: KeyModifiers::default(),
327        next_periodic_redraw: None,
328        last_cursor: Cursor::Default,
329        #[cfg(any(target_os = "android", target_os = "ios"))]
330        ime_allowed: false,
331        pending_resize: None,
332        next_layout_redraw: None,
333        next_paint_redraw: None,
334        next_trigger: FrameTrigger::Initial,
335        last_frame_at: None,
336        last_build: Duration::ZERO,
337        last_prepare: Duration::ZERO,
338        last_layout: Duration::ZERO,
339        last_layout_intrinsic_cache_hits: 0,
340        last_layout_intrinsic_cache_misses: 0,
341        last_layout_pruned_subtrees: 0,
342        last_layout_pruned_nodes: 0,
343        last_draw_ops: Duration::ZERO,
344        last_draw_ops_culled_text_ops: 0,
345        last_paint: Duration::ZERO,
346        last_paint_culled_ops: 0,
347        last_gpu_upload: Duration::ZERO,
348        last_snapshot: Duration::ZERO,
349        last_submit: Duration::ZERO,
350        last_text_layout_cache_hits: 0,
351        last_text_layout_cache_misses: 0,
352        last_text_layout_cache_evictions: 0,
353        last_text_layout_shaped_bytes: 0,
354        frame_index: 0,
355        backend: "?",
356        clipboard,
357        last_primary: String::new(),
358    };
359    event_loop.run_app(&mut host)?;
360    Ok(())
361}
362
363struct Host<A: WinitWgpuApp> {
364    title: &'static str,
365    viewport: Rect,
366    config: HostConfig,
367    app: A,
368    #[cfg(target_os = "android")]
369    android_app: AndroidApp,
370    gfx: Option<Gfx>,
371    /// Last pointer position in logical pixels (winit reports physical;
372    /// we divide by the window's scale factor before storing).
373    last_pointer: Option<(f32, f32)>,
374    modifiers: KeyModifiers,
375    next_periodic_redraw: Option<Instant>,
376    /// Last cursor pushed to `Window::set_cursor`. Avoids redundant
377    /// per-frame calls when the resolved cursor hasn't changed —
378    /// `set_cursor` is cheap but goes through a syscall on most
379    /// platforms.
380    last_cursor: Cursor,
381    /// Last Android soft-keyboard visibility state mirrored from
382    /// `Runner::focused_captures_keys`.
383    #[cfg(any(target_os = "android", target_os = "ios"))]
384    ime_allowed: bool,
385    /// Latest size from `WindowEvent::Resized` not yet applied to the
386    /// surface. Compositors (Wayland especially) deliver a burst of
387    /// resize events during an interactive drag; coalescing them so
388    /// `surface.configure()` + MSAA realloc run once per frame
389    /// instead of once per event keeps the window content from
390    /// trailing the cursor.
391    pending_resize: Option<PhysicalSize<u32>>,
392    /// Wall-clock deadline for the next redraw that needs a full
393    /// rebuild + layout pass — animations settling, widget
394    /// `redraw_within` requests, pending tooltip / toast fades.
395    /// Derived from `prepare.next_layout_redraw_in`. `None` means no
396    /// layout-driven future frame is pending. Cleared after firing.
397    next_layout_redraw: Option<Instant>,
398    /// Wall-clock deadline for the next paint-only redraw — a
399    /// time-driven shader (spinner / skeleton / progress / custom
400    /// `samples_time=true`) needs another frame but layout state is
401    /// unchanged. Serviced via `Renderer::repaint`, which reuses the
402    /// cached ops and only advances `frame.time`. Derived from
403    /// `prepare.next_paint_redraw_in`. Cleared after firing.
404    next_paint_redraw: Option<Instant>,
405    /// Reason the next redraw is being requested. Each event handler
406    /// that calls `request_redraw` sets this beforehand; RedrawRequested
407    /// consumes it and resets to `Other`. Drives [`HostDiagnostics::trigger`]
408    /// for apps that surface a debug overlay.
409    next_trigger: FrameTrigger,
410    /// Wall clock at the start of the previous redraw. Diff with the
411    /// next frame's start gives `last_frame_dt`.
412    last_frame_at: Option<Instant>,
413    /// Timing breakdown from the last completed rendered frame.
414    last_build: Duration,
415    last_prepare: Duration,
416    last_layout: Duration,
417    last_layout_intrinsic_cache_hits: u64,
418    last_layout_intrinsic_cache_misses: u64,
419    last_layout_pruned_subtrees: u64,
420    last_layout_pruned_nodes: u64,
421    last_draw_ops: Duration,
422    last_draw_ops_culled_text_ops: u64,
423    last_paint: Duration,
424    last_paint_culled_ops: u64,
425    last_gpu_upload: Duration,
426    last_snapshot: Duration,
427    last_submit: Duration,
428    last_text_layout_cache_hits: u64,
429    last_text_layout_cache_misses: u64,
430    last_text_layout_cache_evictions: u64,
431    last_text_layout_shaped_bytes: u64,
432    /// Counts redraws actually rendered (not requested). Surfaced via
433    /// [`HostDiagnostics::frame_index`].
434    frame_index: u64,
435    /// Adapter backend tag (`"Vulkan"`, `"Metal"`, `"DX12"`, `"GL"`,
436    /// `"WebGPU"`). Captured once at adapter selection and surfaced in
437    /// the diagnostic overlay.
438    backend: &'static str,
439    /// Best-effort native clipboard. Initialization can fail in
440    /// display-less/headless environments; the host simply leaves copy
441    /// shortcuts as no-ops in that case.
442    clipboard: PlatformClipboard,
443    /// Last text mirrored into Linux's primary selection.
444    last_primary: String,
445}
446
447struct Gfx {
448    // Fields drop in declaration order. GPU resources must go before
449    // the device/window they were created from so shutdown tears them
450    // down before their owners disappear.
451    renderer: Runner,
452    surface: wgpu::Surface<'static>,
453    queue: wgpu::Queue,
454    device: wgpu::Device,
455    window: Arc<Window>,
456    config: wgpu::SurfaceConfiguration,
457    /// Multisampled color attachment for the surface frame, kept in
458    /// sync with `config.width`/`config.height` and reallocated on
459    /// resize. The surface frame texture is the resolve target.
460    msaa: Option<MsaaTarget>,
461}
462
463fn surface_extent(config: &wgpu::SurfaceConfiguration) -> wgpu::Extent3d {
464    wgpu::Extent3d {
465        width: config.width,
466        height: config.height,
467        depth_or_array_layers: 1,
468    }
469}
470
471#[cfg(target_os = "android")]
472fn safe_area_for_window(window: &Window, surface_size: (u32, u32), scale_factor: f32) -> Sides {
473    let rect = window.content_rect();
474    if rect.right <= rect.left || rect.bottom <= rect.top || scale_factor <= 0.0 {
475        return Sides::default();
476    }
477    let (surface_w, surface_h) = (surface_size.0 as i32, surface_size.1 as i32);
478    Sides {
479        left: rect.left.max(0) as f32 / scale_factor,
480        top: rect.top.max(0) as f32 / scale_factor,
481        right: (surface_w - rect.right).max(0) as f32 / scale_factor,
482        bottom: (surface_h - rect.bottom).max(0) as f32 / scale_factor,
483    }
484}
485
486#[cfg(not(target_os = "android"))]
487fn safe_area_for_window(_window: &Window, _surface_size: (u32, u32), _scale_factor: f32) -> Sides {
488    Sides::default()
489}
490
491#[cfg(any(target_os = "android", target_os = "ios"))]
492fn sync_mobile_ime(window: &Window, renderer: &Runner, ime_allowed: &mut bool) {
493    let allowed = renderer.focused_captures_keys();
494    if allowed != *ime_allowed {
495        window.set_ime_allowed(allowed);
496        *ime_allowed = allowed;
497    }
498}
499
500impl<A: WinitWgpuApp> ApplicationHandler for Host<A> {
501    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
502        if self.gfx.is_some() {
503            return;
504        }
505        let attrs = Window::default_attributes()
506            .with_title(self.title)
507            .with_inner_size(PhysicalSize::new(
508                self.viewport.w as u32,
509                self.viewport.h as u32,
510            ));
511        let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
512
513        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
514        let surface = instance
515            .create_surface(window.clone())
516            .expect("create surface");
517
518        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
519            power_preference: wgpu::PowerPreference::default(),
520            compatible_surface: Some(&surface),
521            force_fallback_adapter: false,
522        }))
523        .expect("no compatible adapter");
524        self.backend = backend_label(adapter.get_info().backend);
525
526        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
527            label: Some("aetna_winit_wgpu::device"),
528            required_features: wgpu::Features::empty(),
529            required_limits: wgpu::Limits::default(),
530            experimental_features: wgpu::ExperimentalFeatures::default(),
531            memory_hints: wgpu::MemoryHints::Performance,
532            trace: wgpu::Trace::Off,
533        }))
534        .expect("request_device");
535
536        let size = window.inner_size();
537        let surface_caps = surface.get_capabilities(&adapter);
538        let format = surface_caps
539            .formats
540            .iter()
541            .copied()
542            .find(|f| f.is_srgb())
543            .unwrap_or(surface_caps.formats[0]);
544        // Pick a present mode. `Fifo` is the conservative default —
545        // mandatory in the wgpu spec, vsync-locked, predictable power
546        // cost. `low_latency_present` opts into `Mailbox` (with `Fifo`
547        // fallback) for apps where interaction latency matters more
548        // than steady-state throughput; see `HostConfig` for the
549        // rationale and trade-offs.
550        //
551        // `AETNA_PRESENT_MODE=mailbox|immediate|fifo` overrides at
552        // runtime — useful for diagnosing without a recompile.
553        let mode_override = std::env::var("AETNA_PRESENT_MODE").ok();
554        let prefer_mailbox =
555            self.config.low_latency_present || mode_override.as_deref() == Some("mailbox");
556        let prefer_immediate = mode_override.as_deref() == Some("immediate");
557        let prefer_fifo = mode_override.as_deref() == Some("fifo");
558        let present_mode = if prefer_immediate
559            && surface_caps
560                .present_modes
561                .contains(&wgpu::PresentMode::Immediate)
562        {
563            wgpu::PresentMode::Immediate
564        } else if prefer_mailbox
565            && !prefer_fifo
566            && surface_caps
567                .present_modes
568                .contains(&wgpu::PresentMode::Mailbox)
569        {
570            wgpu::PresentMode::Mailbox
571        } else if surface_caps
572            .present_modes
573            .contains(&wgpu::PresentMode::Fifo)
574        {
575            wgpu::PresentMode::Fifo
576        } else {
577            surface_caps.present_modes[0]
578        };
579        let config = wgpu::SurfaceConfiguration {
580            // COPY_SRC is required so backdrop-sampling shaders can
581            // copy the post-Pass-A surface into the runner's snapshot
582            // texture mid-frame. Cost is minimal — most surfaces
583            // already advertise it.
584            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
585            format,
586            width: size.width.max(1),
587            height: size.height.max(1),
588            present_mode,
589            alpha_mode: surface_caps.alpha_modes[0],
590            view_formats: vec![],
591            // Keep the in-flight queue shallow. With `Fifo` this is a
592            // hint that Mesa's WSI does not always honor — measured
593            // resize lag on Wayland was unaffected by changing this
594            // alone — but it's still the right default: an
595            // interactive UI gains nothing from buffering more than
596            // one frame ahead. Combined with `low_latency_present`
597            // (Mailbox), interactive cadence is bounded by render
598            // time, not by drained queue depth.
599            desired_maximum_frame_latency: 1,
600        };
601        surface.configure(&device, &config);
602
603        let sample_count = self.config.sample_count.max(1);
604        let mut renderer = Runner::with_sample_count(&device, &queue, format, sample_count);
605        renderer.set_theme(self.app.theme());
606        renderer.set_surface_size(config.width, config.height);
607        // Pre-rasterize printable ASCII for Inter + JetBrains Mono so
608        // first-frame appearance of new text labels (e.g. switching
609        // section in the showcase) doesn't trip a 20-30ms MSDF
610        // generation hitch. ~40ms one-off at startup.
611        renderer.warm_default_glyphs();
612        // Register any custom shaders the app declared. Done once at
613        // startup; pipelines are cached for the runner's lifetime.
614        for s in self.app.shaders() {
615            renderer.register_shader_with(
616                &device,
617                s.name,
618                s.wgsl,
619                s.samples_backdrop,
620                s.samples_time,
621            );
622        }
623
624        let msaa = (sample_count > 1)
625            .then(|| MsaaTarget::new(&device, format, surface_extent(&config), sample_count));
626
627        self.gfx = Some(Gfx {
628            renderer,
629            surface,
630            queue,
631            device,
632            window,
633            config,
634            msaa,
635        });
636        // Hand the app the device + queue so it can allocate any GPU
637        // textures it intends to display via `surface()` widgets. Runs
638        // whenever a host GPU context is created; on Android this can
639        // happen again after Activity suspend/resume recreates the
640        // native window.
641        let gfx = self.gfx.as_ref().unwrap();
642        self.app.gpu_setup(&gfx.device, &gfx.queue);
643        self.next_periodic_redraw = self
644            .config
645            .redraw_interval
646            .map(|interval| Instant::now() + interval);
647        gfx.window.request_redraw();
648    }
649
650    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
651        #[cfg(target_os = "android")]
652        {
653            // Android destroys the native window while keeping the Rust
654            // process alive. Any surface/window handles derived from
655            // that native window must be dropped and recreated on the
656            // next `resumed`, otherwise returning from Home can leave a
657            // live process presenting to a dead surface.
658            self.gfx.take();
659            self.pending_resize = None;
660            self.last_pointer = None;
661            self.last_frame_at = None;
662            self.next_periodic_redraw = None;
663            self.ime_allowed = false;
664        }
665    }
666
667    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
668        match event {
669            WindowEvent::CloseRequested => {
670                self.gfx.take();
671                event_loop.exit();
672            }
673
674            event => {
675                let Some(gfx) = self.gfx.as_mut() else {
676                    return;
677                };
678                let scale = gfx.window.scale_factor() as f32;
679
680                match event {
681                    WindowEvent::Resized(size) => {
682                        let w = size.width.max(1);
683                        let h = size.height.max(1);
684                        // Drop no-op resizes the compositor sometimes
685                        // re-sends with the same dimensions — running
686                        // surface.configure() for them just stalls the
687                        // GPU pipeline without changing anything.
688                        let already_pending = self
689                            .pending_resize
690                            .map(|s| s.width == w && s.height == h)
691                            .unwrap_or(false);
692                        let same_as_current = self.pending_resize.is_none()
693                            && w == gfx.config.width
694                            && h == gfx.config.height;
695                        if already_pending || same_as_current {
696                            return;
697                        }
698                        self.pending_resize = Some(PhysicalSize::new(w, h));
699                        self.next_trigger = FrameTrigger::Resize;
700                        gfx.window.request_redraw();
701                    }
702
703                    WindowEvent::CursorMoved { position, .. } => {
704                        let lx = position.x as f32 / scale;
705                        let ly = position.y as f32 / scale;
706                        self.last_pointer = Some((lx, ly));
707                        let moved = gfx.renderer.pointer_moved(Pointer::moving(lx, ly));
708                        for event in moved.events {
709                            dispatch_app_event(
710                                &mut self.app,
711                                event,
712                                &gfx.renderer,
713                                &mut self.clipboard,
714                                &mut self.last_primary,
715                            );
716                        }
717                        // Wayland and most X11 compositors deliver
718                        // CursorMoved at high frequency while the
719                        // cursor is over the surface — only redraw
720                        // when the move actually changed something
721                        // (hovered identity, scrollbar drag, drag
722                        // event), per `PointerMove`.
723                        if moved.needs_redraw {
724                            self.next_trigger = FrameTrigger::Pointer;
725                            gfx.window.request_redraw();
726                        }
727                    }
728
729                    WindowEvent::CursorLeft { .. } => {
730                        self.last_pointer = None;
731                        for event in gfx.renderer.pointer_left() {
732                            dispatch_app_event(
733                                &mut self.app,
734                                event,
735                                &gfx.renderer,
736                                &mut self.clipboard,
737                                &mut self.last_primary,
738                            );
739                        }
740                        self.next_trigger = FrameTrigger::Pointer;
741                        gfx.window.request_redraw();
742                    }
743
744                    WindowEvent::HoveredFile(path) => {
745                        // File hover routes at the current pointer
746                        // position; winit keeps firing CursorMoved
747                        // alongside the file events so `last_pointer`
748                        // tracks the drag in real time.
749                        let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
750                        for event in gfx.renderer.file_hovered(path, lx, ly) {
751                            dispatch_app_event(
752                                &mut self.app,
753                                event,
754                                &gfx.renderer,
755                                &mut self.clipboard,
756                                &mut self.last_primary,
757                            );
758                        }
759                        self.next_trigger = FrameTrigger::Pointer;
760                        gfx.window.request_redraw();
761                    }
762
763                    WindowEvent::HoveredFileCancelled => {
764                        for event in gfx.renderer.file_hover_cancelled() {
765                            dispatch_app_event(
766                                &mut self.app,
767                                event,
768                                &gfx.renderer,
769                                &mut self.clipboard,
770                                &mut self.last_primary,
771                            );
772                        }
773                        self.next_trigger = FrameTrigger::Pointer;
774                        gfx.window.request_redraw();
775                    }
776
777                    WindowEvent::DroppedFile(path) => {
778                        let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
779                        for event in gfx.renderer.file_dropped(path, lx, ly) {
780                            dispatch_app_event(
781                                &mut self.app,
782                                event,
783                                &gfx.renderer,
784                                &mut self.clipboard,
785                                &mut self.last_primary,
786                            );
787                        }
788                        self.next_trigger = FrameTrigger::Pointer;
789                        gfx.window.request_redraw();
790                    }
791
792                    WindowEvent::MouseInput { state, button, .. } => {
793                        let Some(button) = pointer_button(button) else {
794                            return;
795                        };
796                        let Some((lx, ly)) = self.last_pointer else {
797                            return;
798                        };
799                        match state {
800                            ElementState::Pressed => {
801                                for event in
802                                    gfx.renderer.pointer_down(Pointer::mouse(lx, ly, button))
803                                {
804                                    dispatch_app_event(
805                                        &mut self.app,
806                                        event,
807                                        &gfx.renderer,
808                                        &mut self.clipboard,
809                                        &mut self.last_primary,
810                                    );
811                                }
812                                #[cfg(any(target_os = "android", target_os = "ios"))]
813                                sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
814                                self.next_trigger = FrameTrigger::Pointer;
815                                gfx.window.request_redraw();
816                            }
817                            ElementState::Released => {
818                                for event in gfx.renderer.pointer_up(Pointer::mouse(lx, ly, button))
819                                {
820                                    let event =
821                                        attach_primary_selection_text(event, &mut self.clipboard);
822                                    dispatch_app_event(
823                                        &mut self.app,
824                                        event,
825                                        &gfx.renderer,
826                                        &mut self.clipboard,
827                                        &mut self.last_primary,
828                                    );
829                                }
830                                self.next_trigger = FrameTrigger::Pointer;
831                                gfx.window.request_redraw();
832                            }
833                        }
834                    }
835
836                    WindowEvent::MouseWheel { delta, .. } => {
837                        let Some((lx, ly)) = self.last_pointer else {
838                            return;
839                        };
840                        // Convert wheel ticks to logical pixels. Line-based
841                        // deltas come from notched mouse wheels; pixel-based
842                        // from trackpads. ~50 px/line matches typical OS feel.
843                        let dy = match delta {
844                            MouseScrollDelta::LineDelta(_, y) => -y * 50.0,
845                            MouseScrollDelta::PixelDelta(p) => -(p.y as f32) / scale,
846                        };
847                        if gfx.renderer.pointer_wheel(lx, ly, dy) {
848                            self.next_trigger = FrameTrigger::Pointer;
849                            gfx.window.request_redraw();
850                        }
851                    }
852
853                    WindowEvent::ModifiersChanged(modifiers) => {
854                        self.modifiers = key_modifiers(modifiers.state());
855                        gfx.renderer.set_modifiers(self.modifiers);
856                    }
857
858                    WindowEvent::KeyboardInput {
859                        event:
860                            key_event @ winit::event::KeyEvent {
861                                state: ElementState::Pressed,
862                                ..
863                            },
864                        is_synthetic: false,
865                        ..
866                    } => {
867                        if let Some(key) = map_key(&key_event.logical_key) {
868                            for event in
869                                gfx.renderer.key_down(key, self.modifiers, key_event.repeat)
870                            {
871                                match text_input::clipboard_request(&event) {
872                                    Some(ClipboardKind::Copy) => {
873                                        copy_current_selection(&gfx.renderer, &mut self.clipboard);
874                                        dispatch_app_event(
875                                            &mut self.app,
876                                            event,
877                                            &gfx.renderer,
878                                            &mut self.clipboard,
879                                            &mut self.last_primary,
880                                        );
881                                    }
882                                    Some(ClipboardKind::Cut) => {
883                                        copy_current_selection(&gfx.renderer, &mut self.clipboard);
884                                        let delete = clipboard::delete_selection_event(event);
885                                        dispatch_app_event(
886                                            &mut self.app,
887                                            delete,
888                                            &gfx.renderer,
889                                            &mut self.clipboard,
890                                            &mut self.last_primary,
891                                        );
892                                    }
893                                    Some(ClipboardKind::Paste) => {
894                                        if let Some(paste) = paste_text_from_clipboard(
895                                            event.clone(),
896                                            &mut self.clipboard,
897                                        ) {
898                                            dispatch_app_event(
899                                                &mut self.app,
900                                                paste,
901                                                &gfx.renderer,
902                                                &mut self.clipboard,
903                                                &mut self.last_primary,
904                                            );
905                                        } else {
906                                            dispatch_app_event(
907                                                &mut self.app,
908                                                event,
909                                                &gfx.renderer,
910                                                &mut self.clipboard,
911                                                &mut self.last_primary,
912                                            );
913                                        }
914                                    }
915                                    None => dispatch_app_event(
916                                        &mut self.app,
917                                        event,
918                                        &gfx.renderer,
919                                        &mut self.clipboard,
920                                        &mut self.last_primary,
921                                    ),
922                                }
923                            }
924                        }
925                        // Composed text payload (handles Shift+a → "A", dead
926                        // keys, etc). winit attaches this on the same press
927                        // event for non-IME input; IME composition arrives
928                        // separately via `WindowEvent::Ime`.
929                        if let Some(text) = &key_event.text
930                            && let Some(event) = gfx.renderer.text_input(text.to_string())
931                        {
932                            dispatch_app_event(
933                                &mut self.app,
934                                event,
935                                &gfx.renderer,
936                                &mut self.clipboard,
937                                &mut self.last_primary,
938                            );
939                        }
940                        self.next_trigger = FrameTrigger::Keyboard;
941                        gfx.window.request_redraw();
942                    }
943                    WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
944                        if let Some(event) = gfx.renderer.text_input(text) {
945                            dispatch_app_event(
946                                &mut self.app,
947                                event,
948                                &gfx.renderer,
949                                &mut self.clipboard,
950                                &mut self.last_primary,
951                            );
952                        }
953                        self.next_trigger = FrameTrigger::Keyboard;
954                        gfx.window.request_redraw();
955                    }
956
957                    WindowEvent::Touch(touch) => {
958                        let lx = touch.location.x as f32 / scale;
959                        let ly = touch.location.y as f32 / scale;
960                        self.last_pointer = Some((lx, ly));
961                        let mut pointer = Pointer::touch(
962                            lx,
963                            ly,
964                            PointerButton::Primary,
965                            aetna_core::PointerId(touch.id as u32),
966                        );
967                        pointer.pressure = touch_pressure(touch.force);
968                        match touch.phase {
969                            TouchPhase::Started => {
970                                for event in gfx.renderer.pointer_down(pointer) {
971                                    dispatch_app_event(
972                                        &mut self.app,
973                                        event,
974                                        &gfx.renderer,
975                                        &mut self.clipboard,
976                                        &mut self.last_primary,
977                                    );
978                                }
979                                #[cfg(any(target_os = "android", target_os = "ios"))]
980                                sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
981                            }
982                            TouchPhase::Moved => {
983                                let moved = gfx.renderer.pointer_moved(pointer);
984                                for event in moved.events {
985                                    dispatch_app_event(
986                                        &mut self.app,
987                                        event,
988                                        &gfx.renderer,
989                                        &mut self.clipboard,
990                                        &mut self.last_primary,
991                                    );
992                                }
993                                if !moved.needs_redraw {
994                                    return;
995                                }
996                            }
997                            TouchPhase::Ended => {
998                                for event in gfx.renderer.pointer_up(pointer) {
999                                    dispatch_app_event(
1000                                        &mut self.app,
1001                                        event,
1002                                        &gfx.renderer,
1003                                        &mut self.clipboard,
1004                                        &mut self.last_primary,
1005                                    );
1006                                }
1007                                self.last_pointer = None;
1008                            }
1009                            TouchPhase::Cancelled => {
1010                                for event in gfx.renderer.pointer_left() {
1011                                    dispatch_app_event(
1012                                        &mut self.app,
1013                                        event,
1014                                        &gfx.renderer,
1015                                        &mut self.clipboard,
1016                                        &mut self.last_primary,
1017                                    );
1018                                }
1019                                self.last_pointer = None;
1020                            }
1021                        }
1022                        self.next_trigger = FrameTrigger::Pointer;
1023                        gfx.window.request_redraw();
1024                    }
1025
1026                    WindowEvent::RedrawRequested => {
1027                        // Drain time-driven input events (touch
1028                        // long-press today) before this frame's
1029                        // build. The runtime folds the long-press
1030                        // deadline into `next_redraw_in`, so by the
1031                        // time RedrawRequested fires the deadline may
1032                        // have just elapsed; dispatching here ensures
1033                        // the synthesized LongPress event is visible
1034                        // to the App's `build` for this frame.
1035                        for event in gfx.renderer.poll_input(Instant::now()) {
1036                            self.app.on_event(event);
1037                        }
1038                        // Apply the latest coalesced resize, if any,
1039                        // before acquiring the next surface texture so
1040                        // the frame we render matches the size the
1041                        // compositor is asking for.
1042                        if let Some(size) = self.pending_resize.take() {
1043                            gfx.config.width = size.width;
1044                            gfx.config.height = size.height;
1045                            gfx.surface.configure(&gfx.device, &gfx.config);
1046                            gfx.renderer
1047                                .set_surface_size(gfx.config.width, gfx.config.height);
1048                            let extent = surface_extent(&gfx.config);
1049                            if let Some(msaa) = gfx.msaa.as_mut()
1050                                && !msaa.matches(extent)
1051                            {
1052                                *msaa = MsaaTarget::new(
1053                                    &gfx.device,
1054                                    gfx.config.format,
1055                                    extent,
1056                                    msaa.sample_count,
1057                                );
1058                            }
1059                        }
1060                        let frame = match gfx.surface.get_current_texture() {
1061                            wgpu::CurrentSurfaceTexture::Success(t)
1062                            | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
1063                            wgpu::CurrentSurfaceTexture::Lost
1064                            | wgpu::CurrentSurfaceTexture::Outdated => {
1065                                gfx.surface.configure(&gfx.device, &gfx.config);
1066                                return;
1067                            }
1068                            other => {
1069                                eprintln!("surface unavailable: {other:?}");
1070                                return;
1071                            }
1072                        };
1073                        let view = frame
1074                            .texture
1075                            .create_view(&wgpu::TextureViewDescriptor::default());
1076
1077                        // Per-frame GPU update hook — apps writing to
1078                        // their own AppTextures (animated content,
1079                        // 3D viewports, video frames) push pixels to
1080                        // the queue here, before paint records draws
1081                        // that sample those textures.
1082                        // Snapshot diagnostics for this frame: trigger
1083                        // (consumed once — next defaults back to Other),
1084                        // wall-clock since previous frame, surface size,
1085                        // backend tag. Apps read this via `cx.diagnostics()`.
1086                        let frame_start = Instant::now();
1087                        let last_frame_dt = self
1088                            .last_frame_at
1089                            .map(|t| frame_start.duration_since(t))
1090                            .unwrap_or(Duration::ZERO);
1091                        self.last_frame_at = Some(frame_start);
1092                        let trigger = std::mem::take(&mut self.next_trigger);
1093                        let scale_factor = gfx.window.scale_factor() as f32;
1094                        let viewport = Rect::new(
1095                            0.0,
1096                            0.0,
1097                            gfx.config.width as f32 / scale_factor,
1098                            gfx.config.height as f32 / scale_factor,
1099                        );
1100                        // Paint-only path: a time-driven shader's deadline
1101                        // fired but no input / layout signal is queued for
1102                        // this frame, so we skip rebuild + layout and reuse
1103                        // the cached ops. `pending_resize` was applied above
1104                        // and would have set `Resize` instead — but defend
1105                        // against trigger-overwrite races by also requiring
1106                        // it to be empty here.
1107                        let paint_only =
1108                            trigger == FrameTrigger::ShaderPaint && self.pending_resize.is_none();
1109
1110                        let (prepare, palette, t_after_build, t_after_prepare) = if paint_only {
1111                            aetna_core::profile_span!("frame::repaint");
1112                            // No build pass on paint-only frames — reuse
1113                            // the renderer's already-set theme palette
1114                            // (set on the prior full prepare).
1115                            let palette = gfx.renderer.theme().palette().clone();
1116                            let t_after_build = Instant::now();
1117                            let prepare = gfx.renderer.repaint(
1118                                &gfx.device,
1119                                &gfx.queue,
1120                                viewport,
1121                                scale_factor,
1122                            );
1123                            let t_after_prepare = Instant::now();
1124                            (prepare, palette, t_after_build, t_after_prepare)
1125                        } else {
1126                            let msaa_samples =
1127                                gfx.msaa.as_ref().map(|m| m.sample_count).unwrap_or(1);
1128                            self.frame_index = self.frame_index.wrapping_add(1);
1129                            let diagnostics = HostDiagnostics {
1130                                backend: self.backend,
1131                                surface_size: (gfx.config.width, gfx.config.height),
1132                                scale_factor,
1133                                msaa_samples,
1134                                frame_index: self.frame_index,
1135                                last_frame_dt,
1136                                last_build: self.last_build,
1137                                last_prepare: self.last_prepare,
1138                                last_layout: self.last_layout,
1139                                last_layout_intrinsic_cache_hits: self
1140                                    .last_layout_intrinsic_cache_hits,
1141                                last_layout_intrinsic_cache_misses: self
1142                                    .last_layout_intrinsic_cache_misses,
1143                                last_layout_pruned_subtrees: self.last_layout_pruned_subtrees,
1144                                last_layout_pruned_nodes: self.last_layout_pruned_nodes,
1145                                last_draw_ops: self.last_draw_ops,
1146                                last_draw_ops_culled_text_ops: self.last_draw_ops_culled_text_ops,
1147                                last_paint: self.last_paint,
1148                                last_paint_culled_ops: self.last_paint_culled_ops,
1149                                last_gpu_upload: self.last_gpu_upload,
1150                                last_snapshot: self.last_snapshot,
1151                                last_submit: self.last_submit,
1152                                last_text_layout_cache_hits: self.last_text_layout_cache_hits,
1153                                last_text_layout_cache_misses: self.last_text_layout_cache_misses,
1154                                last_text_layout_cache_evictions: self
1155                                    .last_text_layout_cache_evictions,
1156                                last_text_layout_shaped_bytes: self.last_text_layout_shaped_bytes,
1157                                trigger,
1158                            };
1159                            let (mut tree, palette) = {
1160                                aetna_core::profile_span!("frame::build");
1161                                self.app.before_paint(&gfx.queue);
1162                                WinitWgpuApp::before_build(&mut self.app);
1163                                let theme = self.app.theme();
1164                                let palette = theme.palette().clone();
1165                                let cx = aetna_core::BuildCx::new(&theme)
1166                                    .with_ui_state(gfx.renderer.ui_state())
1167                                    .with_diagnostics(&diagnostics)
1168                                    .with_viewport(viewport.w, viewport.h)
1169                                    .with_safe_area(safe_area_for_window(
1170                                        &gfx.window,
1171                                        (gfx.config.width, gfx.config.height),
1172                                        scale_factor,
1173                                    ));
1174                                let tree = self.app.build(&cx);
1175                                gfx.renderer.set_theme(theme);
1176                                gfx.renderer.set_hotkeys(self.app.hotkeys());
1177                                gfx.renderer.set_selection(self.app.selection());
1178                                gfx.renderer.push_toasts(self.app.drain_toasts());
1179                                gfx.renderer
1180                                    .push_focus_requests(self.app.drain_focus_requests());
1181                                gfx.renderer
1182                                    .push_scroll_requests(self.app.drain_scroll_requests());
1183                                for url in self.app.drain_link_opens() {
1184                                    #[cfg(target_os = "android")]
1185                                    open_link(&self.android_app, &url);
1186                                    #[cfg(not(any(target_os = "android", target_os = "ios")))]
1187                                    open_link(&url);
1188                                    #[cfg(target_os = "ios")]
1189                                    open_link(&url);
1190                                }
1191                                (tree, palette)
1192                            };
1193                            let t_after_build = Instant::now();
1194                            let prepare = {
1195                                aetna_core::profile_span!("frame::prepare");
1196                                gfx.renderer.prepare(
1197                                    &gfx.device,
1198                                    &gfx.queue,
1199                                    &mut tree,
1200                                    viewport,
1201                                    scale_factor,
1202                                )
1203                            };
1204                            #[cfg(any(target_os = "android", target_os = "ios"))]
1205                            sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
1206                            let t_after_prepare = Instant::now();
1207                            // Cursor resolution depends on the laid-out tree
1208                            // and the hovered key derived from layout ids,
1209                            // so it only updates on the full-prepare path.
1210                            // Paint-only frames inherit the previous cursor.
1211                            let cursor = gfx.renderer.ui_state().cursor(&tree);
1212                            if cursor != self.last_cursor {
1213                                gfx.window.set_cursor(winit_cursor(cursor));
1214                                self.last_cursor = cursor;
1215                            }
1216                            (prepare, palette, t_after_build, t_after_prepare)
1217                        };
1218
1219                        {
1220                            aetna_core::profile_span!("frame::submit");
1221                            let mut encoder = gfx.device.create_command_encoder(
1222                                &wgpu::CommandEncoderDescriptor {
1223                                    label: Some("aetna_winit_wgpu::encoder"),
1224                                },
1225                            );
1226                            // `render()` owns pass lifetimes itself so it can split
1227                            // around `BackdropSnapshot` boundaries when the app
1228                            // uses backdrop-sampling shaders. With no boundary it
1229                            // collapses to a single pass — same behaviour as the
1230                            // old `draw(pass)` path.
1231                            gfx.renderer.render(
1232                                &gfx.device,
1233                                &mut encoder,
1234                                &frame.texture,
1235                                &view,
1236                                gfx.msaa.as_ref().map(|msaa| &msaa.view),
1237                                wgpu::LoadOp::Clear(bg_color(&palette)),
1238                            );
1239                            gfx.queue.submit(Some(encoder.finish()));
1240                            frame.present();
1241                            let t_after_submit = Instant::now();
1242                            self.last_build = t_after_build - frame_start;
1243                            self.last_prepare = t_after_prepare - t_after_build;
1244                            self.last_submit = t_after_submit - t_after_prepare;
1245                            self.last_layout = prepare.timings.layout;
1246                            self.last_layout_intrinsic_cache_hits =
1247                                prepare.timings.layout_intrinsic_cache.hits;
1248                            self.last_layout_intrinsic_cache_misses =
1249                                prepare.timings.layout_intrinsic_cache.misses;
1250                            self.last_layout_pruned_subtrees =
1251                                prepare.timings.layout_prune.subtrees;
1252                            self.last_layout_pruned_nodes = prepare.timings.layout_prune.nodes;
1253                            self.last_draw_ops = prepare.timings.draw_ops;
1254                            self.last_draw_ops_culled_text_ops =
1255                                prepare.timings.draw_ops_culled_text_ops;
1256                            self.last_paint = prepare.timings.paint;
1257                            self.last_paint_culled_ops = prepare.timings.paint_culled_ops;
1258                            self.last_gpu_upload = prepare.timings.gpu_upload;
1259                            self.last_snapshot = prepare.timings.snapshot;
1260                            self.last_text_layout_cache_hits =
1261                                prepare.timings.text_layout_cache.hits;
1262                            self.last_text_layout_cache_misses =
1263                                prepare.timings.text_layout_cache.misses;
1264                            self.last_text_layout_cache_evictions =
1265                                prepare.timings.text_layout_cache.evictions;
1266                            self.last_text_layout_shaped_bytes =
1267                                prepare.timings.text_layout_cache.shaped_bytes;
1268                        }
1269
1270                        // Two-lane redraw scheduling: split widget /
1271                        // animation deadlines (require rebuild +
1272                        // layout) from time-driven shader deadlines
1273                        // (paint-only is sufficient). Each lane parks
1274                        // its own wake-up; `about_to_wait` chooses the
1275                        // earlier and `RedrawRequested` dispatches to
1276                        // either the full prepare path or the
1277                        // paint-only `repaint` path based on which
1278                        // deadline fired (input handlers naturally
1279                        // upgrade to full by overwriting the trigger).
1280                        //
1281                        // On a paint-only frame, only the paint lane
1282                        // is updated — `repaint` deliberately reports
1283                        // `next_layout_redraw_in = None` because it
1284                        // didn't re-evaluate that signal, so we leave
1285                        // the host's previously-parked layout
1286                        // deadline alone.
1287                        let now = Instant::now();
1288                        if !paint_only {
1289                            match prepare.next_layout_redraw_in {
1290                                None => self.next_layout_redraw = None,
1291                                Some(d) if d.is_zero() => {
1292                                    self.next_layout_redraw = None;
1293                                    self.next_trigger = FrameTrigger::Animation;
1294                                    gfx.window.request_redraw();
1295                                }
1296                                Some(d) => self.next_layout_redraw = Some(now + d),
1297                            }
1298                        }
1299                        match prepare.next_paint_redraw_in {
1300                            None => self.next_paint_redraw = None,
1301                            Some(d) if d.is_zero() => {
1302                                // Don't override an Animation trigger
1303                                // we already set above — layout takes
1304                                // precedence when both fire this turn.
1305                                self.next_paint_redraw = None;
1306                                if !matches!(self.next_trigger, FrameTrigger::Animation) {
1307                                    self.next_trigger = FrameTrigger::ShaderPaint;
1308                                }
1309                                gfx.window.request_redraw();
1310                            }
1311                            Some(d) => self.next_paint_redraw = Some(now + d),
1312                        }
1313                    }
1314                    _ => {}
1315                }
1316            }
1317        }
1318    }
1319
1320    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1321        let Some(gfx) = self.gfx.as_ref() else {
1322            event_loop.set_control_flow(ControlFlow::Wait);
1323            return;
1324        };
1325
1326        let now = Instant::now();
1327
1328        // Refresh the periodic-config wake-up. This is the legacy
1329        // host-config knob; with widgets adopting `redraw_within` it
1330        // becomes unnecessary, but keep it as a manual override for
1331        // hosts that want to force a cadence regardless of what the
1332        // tree asks.
1333        if let Some(interval) = self.config.redraw_interval {
1334            let next = self
1335                .next_periodic_redraw
1336                .get_or_insert_with(|| now + interval);
1337            if now >= *next {
1338                self.next_trigger = FrameTrigger::Periodic;
1339                gfx.window.request_redraw();
1340                *next = now + interval;
1341            }
1342        }
1343
1344        // Pick the earlier wake-up across all three sources: the
1345        // periodic-config knob, the layout deadline (rebuild + full
1346        // prepare), and the paint deadline (paint-only via repaint).
1347        // If a deadline has already passed, fire `request_redraw` and
1348        // clear it; the dispatcher in RedrawRequested reads the
1349        // trigger to decide layout vs paint-only path.
1350        let mut wake_up = self.next_periodic_redraw;
1351        if let Some(t) = self.next_layout_redraw {
1352            if now >= t {
1353                self.next_trigger = FrameTrigger::Animation;
1354                gfx.window.request_redraw();
1355                self.next_layout_redraw = None;
1356            } else {
1357                wake_up = Some(match wake_up {
1358                    Some(p) => p.min(t),
1359                    None => t,
1360                });
1361            }
1362        }
1363        if let Some(t) = self.next_paint_redraw {
1364            if now >= t {
1365                // Layout always wins: if a layout redraw is also queued
1366                // for this turn, take that path and let it re-derive
1367                // the paint deadline from the fresh prepare.
1368                if !matches!(self.next_trigger, FrameTrigger::Animation) {
1369                    self.next_trigger = FrameTrigger::ShaderPaint;
1370                }
1371                gfx.window.request_redraw();
1372                self.next_paint_redraw = None;
1373            } else {
1374                wake_up = Some(match wake_up {
1375                    Some(p) => p.min(t),
1376                    None => t,
1377                });
1378            }
1379        }
1380
1381        match wake_up {
1382            Some(t) => event_loop.set_control_flow(ControlFlow::WaitUntil(t)),
1383            None => event_loop.set_control_flow(ControlFlow::Wait),
1384        }
1385    }
1386}
1387
1388fn map_key(key: &Key) -> Option<UiKey> {
1389    match key {
1390        Key::Named(NamedKey::Enter) => Some(UiKey::Enter),
1391        Key::Named(NamedKey::Escape) => Some(UiKey::Escape),
1392        Key::Named(NamedKey::Tab) => Some(UiKey::Tab),
1393        Key::Named(NamedKey::Space) => Some(UiKey::Space),
1394        Key::Named(NamedKey::ArrowUp) => Some(UiKey::ArrowUp),
1395        Key::Named(NamedKey::ArrowDown) => Some(UiKey::ArrowDown),
1396        Key::Named(NamedKey::ArrowLeft) => Some(UiKey::ArrowLeft),
1397        Key::Named(NamedKey::ArrowRight) => Some(UiKey::ArrowRight),
1398        Key::Named(NamedKey::Backspace) => Some(UiKey::Backspace),
1399        Key::Named(NamedKey::Delete) => Some(UiKey::Delete),
1400        Key::Named(NamedKey::Home) => Some(UiKey::Home),
1401        Key::Named(NamedKey::End) => Some(UiKey::End),
1402        Key::Named(NamedKey::PageUp) => Some(UiKey::PageUp),
1403        Key::Named(NamedKey::PageDown) => Some(UiKey::PageDown),
1404        Key::Character(s) => Some(UiKey::Character(s.to_string())),
1405        Key::Named(named) => Some(UiKey::Other(format!("{named:?}"))),
1406        _ => None,
1407    }
1408}
1409
1410fn pointer_button(b: MouseButton) -> Option<PointerButton> {
1411    match b {
1412        MouseButton::Left => Some(PointerButton::Primary),
1413        MouseButton::Right => Some(PointerButton::Secondary),
1414        MouseButton::Middle => Some(PointerButton::Middle),
1415        // Back / Forward / Other → not surfaced; apps that need them can
1416        // grow the enum.
1417        _ => None,
1418    }
1419}
1420
1421#[cfg(not(any(target_os = "android", target_os = "ios")))]
1422fn new_clipboard() -> PlatformClipboard {
1423    arboard::Clipboard::new().ok()
1424}
1425
1426#[cfg(target_os = "ios")]
1427fn new_clipboard() -> PlatformClipboard {
1428    PlatformClipboard
1429}
1430
1431#[cfg(target_os = "android")]
1432fn new_clipboard(app: &AndroidApp) -> PlatformClipboard {
1433    PlatformClipboard { app: app.clone() }
1434}
1435
1436/// Open a URL surfaced by `App::drain_link_opens` through the OS's
1437/// default URL handler — `xdg-open` on Linux, `start` on Windows,
1438/// `open` on macOS — via the `open` crate. Failures (no handler
1439/// installed, sandboxed environment) are logged rather than panicking.
1440#[cfg(not(any(target_os = "android", target_os = "ios")))]
1441fn open_link(url: &str) {
1442    if let Err(err) = open::that_detached(url) {
1443        eprintln!("aetna-winit-wgpu: failed to open {url}: {err}");
1444    }
1445}
1446
1447#[cfg(target_os = "ios")]
1448fn open_link(url: &str) {
1449    eprintln!("aetna-winit-wgpu: opening links is not wired on iOS yet: {url}");
1450}
1451
1452#[cfg(target_os = "android")]
1453fn open_link(app: &AndroidApp, url: &str) {
1454    let app_for_thread = app.clone();
1455    let url = url.to_string();
1456    app.run_on_java_main_thread(Box::new(move || {
1457        let result = (|| -> jni::errors::Result<()> {
1458            let jvm = unsafe { jni::JavaVM::from_raw(app_for_thread.vm_as_ptr().cast()) };
1459            jvm.attach_current_thread(|env| {
1460                let url = env.new_string(&url)?;
1461                let uri = env
1462                    .call_static_method(
1463                        jni::jni_str!("android/net/Uri"),
1464                        jni::jni_str!("parse"),
1465                        jni::jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"),
1466                        &[jni::JValue::Object(url.as_ref())],
1467                    )?
1468                    .l()?;
1469                let action = env
1470                    .get_static_field(
1471                        jni::jni_str!("android/content/Intent"),
1472                        jni::jni_str!("ACTION_VIEW"),
1473                        jni::jni_sig!("Ljava/lang/String;"),
1474                    )?
1475                    .l()?;
1476                let intent = env.new_object(
1477                    jni::jni_str!("android/content/Intent"),
1478                    jni::jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"),
1479                    &[jni::JValue::Object(&action), jni::JValue::Object(&uri)],
1480                )?;
1481                let activity = unsafe {
1482                    jni::objects::JObject::from_raw(
1483                        env,
1484                        app_for_thread.activity_as_ptr() as jni::sys::jobject,
1485                    )
1486                };
1487                env.call_method(
1488                    &activity,
1489                    jni::jni_str!("startActivity"),
1490                    jni::jni_sig!("(Landroid/content/Intent;)V"),
1491                    &[jni::JValue::Object(&intent)],
1492                )?;
1493                Ok(())
1494            })
1495        })();
1496        if let Err(err) = result {
1497            eprintln!("aetna-winit-wgpu: failed to open link on Android: {err}");
1498        }
1499    }));
1500}
1501
1502fn touch_pressure(force: Option<Force>) -> Option<f32> {
1503    match force? {
1504        Force::Calibrated {
1505            force,
1506            max_possible_force,
1507            ..
1508        } if max_possible_force > 0.0 => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
1509        Force::Calibrated { force, .. } => Some(force.clamp(0.0, 1.0) as f32),
1510        Force::Normalized(v) => Some(v.clamp(0.0, 1.0) as f32),
1511    }
1512}
1513
1514/// Translate an Aetna [`Cursor`] to winit's [`CursorIcon`]. The Aetna
1515/// enum is a subset of winit's so this stays a 1:1 map; the wildcard
1516/// arm is a forward-compat safety net (Aetna's `Cursor` is
1517/// `non_exhaustive` — add a new variant in core, add the matching arm
1518/// here, otherwise it falls back to the platform default).
1519fn winit_cursor(cursor: Cursor) -> CursorIcon {
1520    match cursor {
1521        Cursor::Default => CursorIcon::Default,
1522        Cursor::Pointer => CursorIcon::Pointer,
1523        Cursor::Text => CursorIcon::Text,
1524        Cursor::NotAllowed => CursorIcon::NotAllowed,
1525        Cursor::Grab => CursorIcon::Grab,
1526        Cursor::Grabbing => CursorIcon::Grabbing,
1527        Cursor::Move => CursorIcon::Move,
1528        Cursor::EwResize => CursorIcon::EwResize,
1529        Cursor::NsResize => CursorIcon::NsResize,
1530        Cursor::NwseResize => CursorIcon::NwseResize,
1531        Cursor::NeswResize => CursorIcon::NeswResize,
1532        Cursor::ColResize => CursorIcon::ColResize,
1533        Cursor::RowResize => CursorIcon::RowResize,
1534        Cursor::Crosshair => CursorIcon::Crosshair,
1535        _ => CursorIcon::Default,
1536    }
1537}
1538
1539fn key_modifiers(mods: winit::keyboard::ModifiersState) -> KeyModifiers {
1540    KeyModifiers {
1541        shift: mods.shift_key(),
1542        ctrl: mods.control_key(),
1543        alt: mods.alt_key(),
1544        logo: mods.super_key(),
1545    }
1546}
1547
1548fn bg_color(palette: &aetna_core::Palette) -> wgpu::Color {
1549    let c = palette.background;
1550    wgpu::Color {
1551        r: srgb_to_linear(c.r as f64 / 255.0),
1552        g: srgb_to_linear(c.g as f64 / 255.0),
1553        b: srgb_to_linear(c.b as f64 / 255.0),
1554        a: c.a as f64 / 255.0,
1555    }
1556}
1557
1558fn copy_current_selection(renderer: &Runner, clipboard: &mut PlatformClipboard) {
1559    // Read the selection out of `last_tree` (via the runtime helper) —
1560    // see `RunnerCore::selected_text` for why a build-only path would
1561    // miss selections inside a virtual list.
1562    let Some(text) = renderer.selected_text() else {
1563        return;
1564    };
1565    set_clipboard_text(clipboard, text);
1566}
1567
1568fn dispatch_app_event<A: App>(
1569    app: &mut A,
1570    event: UiEvent,
1571    renderer: &Runner,
1572    clipboard: &mut PlatformClipboard,
1573    last_primary: &mut String,
1574) {
1575    let before = app.selection();
1576    app.on_event(event);
1577    if app.selection() != before {
1578        sync_primary_selection(&app.selection(), renderer, clipboard, last_primary);
1579    }
1580}
1581
1582fn sync_primary_selection(
1583    selection: &aetna_core::selection::Selection,
1584    renderer: &Runner,
1585    clipboard: &mut PlatformClipboard,
1586    last_primary: &mut String,
1587) {
1588    let text = renderer
1589        .selected_text_for(selection)
1590        .filter(|s| !s.is_empty())
1591        .unwrap_or_default();
1592    if text == *last_primary {
1593        return;
1594    }
1595    if !text.is_empty() {
1596        primary::set(clipboard, &text);
1597    }
1598    *last_primary = text;
1599}
1600
1601fn paste_text_from_clipboard(event: UiEvent, clipboard: &mut PlatformClipboard) -> Option<UiEvent> {
1602    let text = get_clipboard_text(clipboard)?;
1603    Some(clipboard::paste_text_event(event, text))
1604}
1605
1606fn attach_primary_selection_text(mut event: UiEvent, clipboard: &mut PlatformClipboard) -> UiEvent {
1607    if event.kind == UiEventKind::MiddleClick {
1608        event.text = primary::get(clipboard);
1609    }
1610    event
1611}
1612
1613#[cfg(not(any(target_os = "android", target_os = "ios")))]
1614fn set_clipboard_text(clipboard: &mut PlatformClipboard, text: String) {
1615    if let Some(cb) = clipboard {
1616        let _ = cb.set_text(text);
1617    }
1618}
1619
1620#[cfg(target_os = "ios")]
1621fn set_clipboard_text(_clipboard: &mut PlatformClipboard, _text: String) {}
1622
1623#[cfg(target_os = "android")]
1624fn set_clipboard_text(clipboard: &mut PlatformClipboard, text: String) {
1625    if let Err(err) = set_android_clipboard_text(&clipboard.app, &text) {
1626        eprintln!("aetna-winit-wgpu: failed to set Android clipboard: {err}");
1627    }
1628}
1629
1630#[cfg(not(any(target_os = "android", target_os = "ios")))]
1631fn get_clipboard_text(clipboard: &mut PlatformClipboard) -> Option<String> {
1632    clipboard.as_mut()?.get_text().ok()
1633}
1634
1635#[cfg(target_os = "ios")]
1636fn get_clipboard_text(_clipboard: &mut PlatformClipboard) -> Option<String> {
1637    None
1638}
1639
1640#[cfg(target_os = "android")]
1641fn get_clipboard_text(clipboard: &mut PlatformClipboard) -> Option<String> {
1642    match get_android_clipboard_text(&clipboard.app) {
1643        Ok(text) => text,
1644        Err(err) => {
1645            eprintln!("aetna-winit-wgpu: failed to read Android clipboard: {err}");
1646            None
1647        }
1648    }
1649}
1650
1651#[cfg(target_os = "android")]
1652fn set_android_clipboard_text(app: &AndroidApp, text: &str) -> jni::errors::Result<()> {
1653    use jni::refs::Reference as _;
1654
1655    let jvm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
1656    jvm.attach_current_thread(|env| {
1657        let activity = unsafe {
1658            jni::objects::JObject::from_raw(env, app.activity_as_ptr() as jni::sys::jobject)
1659        };
1660        let service_name = env.new_string("clipboard")?;
1661        let clipboard = env
1662            .call_method(
1663                &activity,
1664                jni::jni_str!("getSystemService"),
1665                jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
1666                &[jni::JValue::Object(service_name.as_ref())],
1667            )?
1668            .l()?;
1669        if clipboard.is_null() {
1670            return Ok(());
1671        }
1672
1673        let label = env.new_string("Aetna")?;
1674        let text = env.new_string(text)?;
1675        let clip = env
1676            .call_static_method(
1677                jni::jni_str!("android/content/ClipData"),
1678                jni::jni_str!("newPlainText"),
1679                jni::jni_sig!(
1680                    "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;"
1681                ),
1682                &[
1683                    jni::JValue::Object(label.as_ref()),
1684                    jni::JValue::Object(text.as_ref()),
1685                ],
1686            )?
1687            .l()?;
1688        env.call_method(
1689            &clipboard,
1690            jni::jni_str!("setPrimaryClip"),
1691            jni::jni_sig!("(Landroid/content/ClipData;)V"),
1692            &[jni::JValue::Object(&clip)],
1693        )?;
1694        Ok(())
1695    })
1696}
1697
1698#[cfg(target_os = "android")]
1699fn get_android_clipboard_text(app: &AndroidApp) -> jni::errors::Result<Option<String>> {
1700    use jni::refs::Reference as _;
1701
1702    let jvm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
1703    jvm.attach_current_thread(|env| {
1704        let activity = unsafe {
1705            jni::objects::JObject::from_raw(env, app.activity_as_ptr() as jni::sys::jobject)
1706        };
1707        let service_name = env.new_string("clipboard")?;
1708        let clipboard = env
1709            .call_method(
1710                &activity,
1711                jni::jni_str!("getSystemService"),
1712                jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
1713                &[jni::JValue::Object(service_name.as_ref())],
1714            )?
1715            .l()?;
1716        if clipboard.is_null() {
1717            return Ok(None);
1718        }
1719
1720        let clip = env
1721            .call_method(
1722                &clipboard,
1723                jni::jni_str!("getPrimaryClip"),
1724                jni::jni_sig!("()Landroid/content/ClipData;"),
1725                &[],
1726            )?
1727            .l()?;
1728        if clip.is_null() {
1729            return Ok(None);
1730        }
1731
1732        let item_count = env
1733            .call_method(
1734                &clip,
1735                jni::jni_str!("getItemCount"),
1736                jni::jni_sig!("()I"),
1737                &[],
1738            )?
1739            .i()?;
1740        if item_count <= 0 {
1741            return Ok(None);
1742        }
1743
1744        let item = env
1745            .call_method(
1746                &clip,
1747                jni::jni_str!("getItemAt"),
1748                jni::jni_sig!("(I)Landroid/content/ClipData$Item;"),
1749                &[jni::JValue::Int(0)],
1750            )?
1751            .l()?;
1752        if item.is_null() {
1753            return Ok(None);
1754        }
1755
1756        let text = env
1757            .call_method(
1758                &item,
1759                jni::jni_str!("coerceToText"),
1760                jni::jni_sig!("(Landroid/content/Context;)Ljava/lang/CharSequence;"),
1761                &[jni::JValue::Object(&activity)],
1762            )?
1763            .l()?;
1764        if text.is_null() {
1765            return Ok(None);
1766        }
1767
1768        let text = env
1769            .call_method(
1770                &text,
1771                jni::jni_str!("toString"),
1772                jni::jni_sig!("()Ljava/lang/String;"),
1773                &[],
1774            )?
1775            .l()?;
1776        if text.is_null() {
1777            return Ok(None);
1778        }
1779
1780        let text = env.cast_local::<jni::objects::JString>(text)?;
1781        Ok(Some(text.try_to_string(env)?))
1782    })
1783}
1784
1785mod primary {
1786    #[cfg(target_os = "linux")]
1787    pub fn set(clipboard: &mut super::PlatformClipboard, text: &str) {
1788        use arboard::{LinuxClipboardKind, SetExtLinux};
1789        if let Some(cb) = clipboard {
1790            let _ = cb.set().clipboard(LinuxClipboardKind::Primary).text(text);
1791        }
1792    }
1793
1794    #[cfg(target_os = "linux")]
1795    pub fn get(clipboard: &mut super::PlatformClipboard) -> Option<String> {
1796        use arboard::{GetExtLinux, LinuxClipboardKind};
1797        let cb = clipboard.as_mut()?;
1798        cb.get().clipboard(LinuxClipboardKind::Primary).text().ok()
1799    }
1800
1801    #[cfg(not(target_os = "linux"))]
1802    pub fn set(_clipboard: &mut super::PlatformClipboard, _text: &str) {}
1803
1804    #[cfg(not(target_os = "linux"))]
1805    pub fn get(_clipboard: &mut super::PlatformClipboard) -> Option<String> {
1806        None
1807    }
1808}
1809
1810/// Stable, human-readable tag for the wgpu backend in use. Surfaced to
1811/// apps via [`HostDiagnostics::backend`]; the showcase's debug overlay
1812/// renders this as-is. `BrowserWebGpu` is collapsed to `"WebGPU"` on
1813/// the assumption that browser-side telemetry already says "Chromium"
1814/// or "Firefox" elsewhere.
1815fn backend_label(backend: wgpu::Backend) -> &'static str {
1816    match backend {
1817        wgpu::Backend::Vulkan => "Vulkan",
1818        wgpu::Backend::Metal => "Metal",
1819        wgpu::Backend::Dx12 => "DX12",
1820        wgpu::Backend::Gl => "GL",
1821        wgpu::Backend::BrowserWebGpu => "WebGPU",
1822        wgpu::Backend::Noop => "noop",
1823    }
1824}
1825
1826/// Surface format is sRGB, but `wgpu::Color::Clear` is taken as
1827/// linear-space — convert so the clear color matches our token.
1828fn srgb_to_linear(c: f64) -> f64 {
1829    if c <= 0.04045 {
1830        c / 12.92
1831    } else {
1832        ((c + 0.055) / 1.055).powf(2.4)
1833    }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838    use super::*;
1839    use aetna_core::Selection;
1840    use aetna_core::SelectionPoint;
1841    use aetna_core::SelectionRange;
1842
1843    /// `BasicApp` is the wrapper the host uses around the user's app
1844    /// type. It must forward every per-frame App trait method to the
1845    /// inner type — a missing forward silently falls through to the
1846    /// trait default and the host loses sight of app state. A
1847    /// previous bug had `selection()` left out, which made the
1848    /// painter never receive a non-empty selection.
1849    #[test]
1850    fn basic_app_forwards_selection_to_inner() {
1851        struct AppWithSelection;
1852        impl App for AppWithSelection {
1853            fn build(&self, _cx: &aetna_core::BuildCx) -> aetna_core::El {
1854                aetna_core::widgets::text::text("hi")
1855            }
1856            fn selection(&self) -> Selection {
1857                Selection {
1858                    range: Some(SelectionRange {
1859                        anchor: SelectionPoint::new("p", 0),
1860                        head: SelectionPoint::new("p", 5),
1861                    }),
1862                }
1863            }
1864        }
1865        let basic = BasicApp(AppWithSelection);
1866        let sel = basic.selection();
1867        let r = sel.range.as_ref().expect("range forwarded through wrapper");
1868        assert_eq!(r.anchor.key, "p");
1869        assert_eq!(r.head.byte, 5);
1870    }
1871}