Skip to main content

damascene_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//! `damascene-wgpu` directly:
6//!
7//! ```ignore
8//! use damascene_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//!     damascene_winit_wgpu::run("My Damascene 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 Damascene 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 `damascene_wgpu::Runner`
39//! directly.
40
41use std::{
42    sync::Arc,
43    time::{Duration, Instant},
44};
45
46use damascene_core::color::{ColorManagementStatus, ColorPreferences};
47use damascene_core::widgets::text_input::{self, ClipboardKind};
48use damascene_core::{
49    App, Cursor, FrameTrigger, HostDiagnostics, KeyModifiers, Pointer, PointerButton, Rect, Sides,
50    UiEvent, UiEventKind, UiKey, clipboard,
51};
52use damascene_wgpu::{MsaaTarget, Runner};
53
54#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
55mod wayland_color;
56
57const DEFAULT_SAMPLE_COUNT: u32 = 4;
58#[cfg(not(any(target_os = "android", target_os = "ios")))]
59type PlatformClipboard = Option<arboard::Clipboard>;
60#[cfg(target_os = "android")]
61struct PlatformClipboard {
62    app: AndroidApp,
63}
64#[cfg(target_os = "ios")]
65#[derive(Default)]
66struct PlatformClipboard;
67
68use winit::application::ApplicationHandler;
69use winit::dpi::PhysicalSize;
70use winit::event::{ElementState, Force, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
71use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
72use winit::keyboard::{Key, NamedKey};
73#[cfg(target_os = "android")]
74use winit::platform::android::{EventLoopExtAndroid, WindowExtAndroid, activity::AndroidApp};
75use winit::window::{CursorIcon, Window, WindowId};
76
77/// Configuration for the optional native winit + wgpu host.
78#[derive(Clone, Debug)]
79pub struct HostConfig {
80    /// MSAA sample count used for Damascene's SDF surfaces. The default is
81    /// 4, matching the demo and validation app paths.
82    pub sample_count: u32,
83    /// Optional fixed redraw cadence for apps with external live data
84    /// sources such as audio meters. Animation-driven redraws still
85    /// come from `Runner::prepare().needs_redraw`; this is only for
86    /// host-owned clocks.
87    pub redraw_interval: Option<Duration>,
88    /// Prefer the lowest-latency wgpu present mode the surface
89    /// advertises (`Mailbox`, falling back to `Fifo`). Default is
90    /// `Fifo`, which is vsync-locked and conservative on power.
91    ///
92    /// Why this exists: with `Fifo`, every submit queues a frame for
93    /// the next vsync; if the app submits faster than the display
94    /// refresh, the compositor pulls the *oldest* queued frame at
95    /// each vsync. On Wayland/Mesa during an interactive resize this
96    /// shows up as the window content trailing the cursor in slow
97    /// motion — by the time the latest size we rendered reaches the
98    /// screen, several more compositor `configure` events have
99    /// arrived. `Mailbox` replaces the pending frame on each submit,
100    /// so the next vsync always shows the most recent render.
101    ///
102    /// Cost: with `Mailbox`, render cadence is no longer naturally
103    /// vsync-bounded — an animation that calls `request_redraw` from
104    /// `prepare.needs_redraw` will render at GPU speed. Pair this
105    /// with `redraw_interval` (or accept the cycles) if that's not
106    /// what you want.
107    pub low_latency_present: bool,
108    /// Stable identifier used by the windowing system / compositor /
109    /// desktop services to group windows under this application.
110    ///
111    /// - **Wayland**: sets `xdg_toplevel.app_id`. Should match the
112    ///   basename of the `.desktop` file the app ships (reverse-DNS
113    ///   by convention, e.g. `com.example.MyApp`).
114    /// - **X11**: sets both fields of `WM_CLASS` to the same value.
115    /// - **Windows / macOS / mobile**: ignored.
116    ///
117    /// When `None`, windowing-system defaults apply — typically the
118    /// process name on Wayland, which several compositors render as
119    /// a generic placeholder (e.g. `surface-transient`) in their
120    /// config UIs and XDG-portal-backed system dialogs.
121    pub app_id: Option<String>,
122    /// App's color-space preferences.
123    ///
124    /// **Mostly advisory.** We never attach an image description to the
125    /// surface — per `wp_color_management_v1` a surface has a single
126    /// color-management owner, and for an accelerated client that is the
127    /// wgpu/Vulkan WSI, not us. We do read the compositor's color-management
128    /// state (for the Color Management showcase page) and, on a genuinely
129    /// HDR output, select an extended-range float swapchain (`Rgba16Float` →
130    /// scRGB via the WSI) so `>1.0` values reach the display; SDR outputs
131    /// stay on the 8-bit sRGB baseline. The default is
132    /// `ColorPreferences::sdr_only()`.
133    pub color_preferences: ColorPreferences,
134}
135
136impl Default for HostConfig {
137    fn default() -> Self {
138        Self {
139            sample_count: DEFAULT_SAMPLE_COUNT,
140            redraw_interval: None,
141            low_latency_present: false,
142            app_id: None,
143            color_preferences: ColorPreferences::default(),
144        }
145    }
146}
147
148impl HostConfig {
149    pub fn with_redraw_interval(mut self, interval: Duration) -> Self {
150        self.redraw_interval = Some(interval);
151        self
152    }
153
154    pub fn with_sample_count(mut self, sample_count: u32) -> Self {
155        self.sample_count = sample_count.max(1);
156        self
157    }
158
159    pub fn with_low_latency_present(mut self, low_latency_present: bool) -> Self {
160        self.low_latency_present = low_latency_present;
161        self
162    }
163
164    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
165        self.app_id = Some(app_id.into());
166        self
167    }
168
169    pub fn with_color_preferences(mut self, color_preferences: ColorPreferences) -> Self {
170        self.color_preferences = color_preferences;
171        self
172    }
173}
174
175/// Compatibility extension point for apps that use this host crate.
176///
177/// New apps should prefer [`App::before_build`]. This trait remains for
178/// code that wants to name a winit-host-specific app type while still
179/// using the same core lifecycle, and as a place to hang wgpu-specific
180/// hooks that the backend-neutral [`App`] trait can't carry — see
181/// [`Self::gpu_setup`] and [`Self::before_paint`].
182pub trait WinitWgpuApp: App {
183    fn before_build(&mut self) {
184        App::before_build(self);
185    }
186
187    /// Called once after the host has created its `wgpu::Device` and
188    /// before the first frame is drawn. Apps that need to allocate
189    /// app-owned GPU textures (typically for use with
190    /// [`damascene_core::surface::AppTexture`] / `surface()` widgets)
191    /// initialize them here.
192    ///
193    /// Default: no-op. App authors who don't touch wgpu directly can
194    /// ignore this hook.
195    fn gpu_setup(&mut self, _device: &wgpu::Device, _queue: &wgpu::Queue) {}
196
197    /// Called each frame just before [`App::build`] runs. Apps update
198    /// their app-owned GPU textures here — typically by
199    /// `queue.write_texture(...)` of the next animation frame so the
200    /// composite the runner draws this frame samples fresh pixels.
201    ///
202    /// Default: no-op.
203    fn before_paint(&mut self, _queue: &wgpu::Queue) {}
204}
205
206struct BasicApp<A>(A);
207
208impl<A: App> App for BasicApp<A> {
209    fn before_build(&mut self) {
210        self.0.before_build();
211    }
212
213    fn build(&self, cx: &damascene_core::BuildCx) -> damascene_core::El {
214        self.0.build(cx)
215    }
216
217    fn on_event(&mut self, event: damascene_core::UiEvent) {
218        self.0.on_event(event);
219    }
220
221    fn on_wheel_event(&mut self, event: damascene_core::UiEvent) -> bool {
222        self.0.on_wheel_event(event)
223    }
224
225    fn hotkeys(&self) -> Vec<(damascene_core::KeyChord, String)> {
226        self.0.hotkeys()
227    }
228
229    fn drain_toasts(&mut self) -> Vec<damascene_core::toast::ToastSpec> {
230        self.0.drain_toasts()
231    }
232
233    fn drain_focus_requests(&mut self) -> Vec<String> {
234        self.0.drain_focus_requests()
235    }
236
237    fn drain_scroll_requests(&mut self) -> Vec<damascene_core::scroll::ScrollRequest> {
238        self.0.drain_scroll_requests()
239    }
240
241    fn drain_link_opens(&mut self) -> Vec<String> {
242        self.0.drain_link_opens()
243    }
244
245    fn shaders(&self) -> Vec<damascene_core::AppShader> {
246        self.0.shaders()
247    }
248
249    fn theme(&self) -> damascene_core::Theme {
250        self.0.theme()
251    }
252
253    fn selection(&self) -> damascene_core::Selection {
254        self.0.selection()
255    }
256}
257
258impl<A: App> WinitWgpuApp for BasicApp<A> {}
259
260/// Run a windowed app. Blocks until the user closes the window.
261///
262/// The `App` is owned by the runner; its `&mut self` is updated in
263/// response to routed events and read on every `build` call.
264pub fn run<A: App + 'static>(
265    title: &'static str,
266    viewport: Rect,
267    app: A,
268) -> Result<(), Box<dyn std::error::Error>> {
269    run_host(title, viewport, BasicApp(app), HostConfig::default())
270}
271
272/// Run a windowed app with host-specific configuration.
273///
274/// Use this when a plain [`App`] wants a host cadence
275/// (`redraw_interval`) or non-default MSAA. For fully custom
276/// render-loop integration, bypass this crate and call
277/// `damascene_wgpu::Runner` directly.
278pub fn run_with_config<A: App + 'static>(
279    title: &'static str,
280    viewport: Rect,
281    app: A,
282    config: HostConfig,
283) -> Result<(), Box<dyn std::error::Error>> {
284    run_host(title, viewport, BasicApp(app), config)
285}
286
287/// Run a plain [`App`] using a caller-created winit event loop.
288///
289/// This is primarily for platform hosts that need to configure the
290/// event loop before Damascene owns it. Android, for example, must attach
291/// the `AndroidApp` received by `android_main` before `build()`.
292pub fn run_on_event_loop<A: App + 'static>(
293    event_loop: EventLoop<()>,
294    title: &'static str,
295    viewport: Rect,
296    app: A,
297    config: HostConfig,
298) -> Result<(), Box<dyn std::error::Error>> {
299    run_host_on_event_loop(event_loop, title, viewport, BasicApp(app), config)
300}
301
302/// Run a windowed app with host-specific configuration.
303///
304/// Prefer [`run_with_config`] for new apps; [`App::before_build`] is
305/// available there as well.
306pub fn run_host_app_with_config<A: WinitWgpuApp + 'static>(
307    title: &'static str,
308    viewport: Rect,
309    app: A,
310    config: HostConfig,
311) -> Result<(), Box<dyn std::error::Error>> {
312    run_host(title, viewport, app, config)
313}
314
315/// Run a host-specific [`WinitWgpuApp`] using a caller-created winit
316/// event loop.
317pub fn run_host_app_on_event_loop<A: WinitWgpuApp + 'static>(
318    event_loop: EventLoop<()>,
319    title: &'static str,
320    viewport: Rect,
321    app: A,
322    config: HostConfig,
323) -> Result<(), Box<dyn std::error::Error>> {
324    run_host_on_event_loop(event_loop, title, viewport, app, config)
325}
326
327/// Run a windowed app with default host configuration.
328///
329/// Prefer [`run`] for new apps; [`App::before_build`] is available
330/// there as well.
331pub fn run_host_app<A: WinitWgpuApp + 'static>(
332    title: &'static str,
333    viewport: Rect,
334    app: A,
335) -> Result<(), Box<dyn std::error::Error>> {
336    run_host(title, viewport, app, HostConfig::default())
337}
338
339fn run_host<A: WinitWgpuApp + 'static>(
340    title: &'static str,
341    viewport: Rect,
342    app: A,
343    config: HostConfig,
344) -> Result<(), Box<dyn std::error::Error>> {
345    let event_loop = EventLoop::new()?;
346    run_host_on_event_loop(event_loop, title, viewport, app, config)
347}
348
349fn run_host_on_event_loop<A: WinitWgpuApp + 'static>(
350    event_loop: EventLoop<()>,
351    title: &'static str,
352    viewport: Rect,
353    app: A,
354    config: HostConfig,
355) -> Result<(), Box<dyn std::error::Error>> {
356    event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
357    #[cfg(target_os = "android")]
358    let android_app = event_loop.android_app().clone();
359    #[cfg(not(target_os = "android"))]
360    let clipboard = new_clipboard();
361    #[cfg(target_os = "android")]
362    let clipboard = new_clipboard(&android_app);
363    let mut host = Host {
364        title,
365        viewport,
366        config,
367        app,
368        #[cfg(target_os = "android")]
369        android_app,
370        gfx: None,
371        last_pointer: None,
372        modifiers: KeyModifiers::default(),
373        next_periodic_redraw: None,
374        last_cursor: Cursor::Default,
375        #[cfg(any(target_os = "android", target_os = "ios"))]
376        ime_allowed: false,
377        pending_resize: None,
378        next_layout_redraw: None,
379        next_paint_redraw: None,
380        next_trigger: FrameTrigger::Initial,
381        last_frame_at: None,
382        last_build: Duration::ZERO,
383        last_prepare: Duration::ZERO,
384        last_layout: Duration::ZERO,
385        last_layout_intrinsic_cache_hits: 0,
386        last_layout_intrinsic_cache_misses: 0,
387        last_layout_pruned_subtrees: 0,
388        last_layout_pruned_nodes: 0,
389        last_draw_ops: Duration::ZERO,
390        last_draw_ops_culled_text_ops: 0,
391        last_paint: Duration::ZERO,
392        last_paint_culled_ops: 0,
393        last_gpu_upload: Duration::ZERO,
394        last_snapshot: Duration::ZERO,
395        last_submit: Duration::ZERO,
396        last_text_layout_cache_hits: 0,
397        last_text_layout_cache_misses: 0,
398        last_text_layout_cache_evictions: 0,
399        last_text_layout_shaped_bytes: 0,
400        frame_index: 0,
401        backend: "?",
402        clipboard,
403        last_primary: String::new(),
404    };
405    event_loop.run_app(&mut host)?;
406    Ok(())
407}
408
409struct Host<A: WinitWgpuApp> {
410    title: &'static str,
411    viewport: Rect,
412    config: HostConfig,
413    app: A,
414    #[cfg(target_os = "android")]
415    android_app: AndroidApp,
416    gfx: Option<Gfx>,
417    /// Last pointer position in logical pixels (winit reports physical;
418    /// we divide by the window's scale factor before storing).
419    last_pointer: Option<(f32, f32)>,
420    modifiers: KeyModifiers,
421    next_periodic_redraw: Option<Instant>,
422    /// Last cursor pushed to `Window::set_cursor`. Avoids redundant
423    /// per-frame calls when the resolved cursor hasn't changed —
424    /// `set_cursor` is cheap but goes through a syscall on most
425    /// platforms.
426    last_cursor: Cursor,
427    /// Last Android soft-keyboard visibility state mirrored from
428    /// `Runner::focused_captures_keys`.
429    #[cfg(any(target_os = "android", target_os = "ios"))]
430    ime_allowed: bool,
431    /// Latest size from `WindowEvent::Resized` not yet applied to the
432    /// surface. Compositors (Wayland especially) deliver a burst of
433    /// resize events during an interactive drag; coalescing them so
434    /// `surface.configure()` + MSAA realloc run once per frame
435    /// instead of once per event keeps the window content from
436    /// trailing the cursor.
437    pending_resize: Option<PhysicalSize<u32>>,
438    /// Wall-clock deadline for the next redraw that needs a full
439    /// rebuild + layout pass — animations settling, widget
440    /// `redraw_within` requests, pending tooltip / toast fades.
441    /// Derived from `prepare.next_layout_redraw_in`. `None` means no
442    /// layout-driven future frame is pending. Cleared after firing.
443    next_layout_redraw: Option<Instant>,
444    /// Wall-clock deadline for the next paint-only redraw — a
445    /// time-driven shader (spinner / skeleton / progress / custom
446    /// `samples_time=true`) needs another frame but layout state is
447    /// unchanged. Serviced via `Renderer::repaint`, which reuses the
448    /// cached ops and only advances `frame.time`. Derived from
449    /// `prepare.next_paint_redraw_in`. Cleared after firing.
450    next_paint_redraw: Option<Instant>,
451    /// Reason the next redraw is being requested. Each event handler
452    /// that calls `request_redraw` sets this beforehand; RedrawRequested
453    /// consumes it and resets to `Other`. Drives [`HostDiagnostics::trigger`]
454    /// for apps that surface a debug overlay.
455    next_trigger: FrameTrigger,
456    /// Wall clock at the start of the previous redraw. Diff with the
457    /// next frame's start gives `last_frame_dt`.
458    last_frame_at: Option<Instant>,
459    /// Timing breakdown from the last completed rendered frame.
460    last_build: Duration,
461    last_prepare: Duration,
462    last_layout: Duration,
463    last_layout_intrinsic_cache_hits: u64,
464    last_layout_intrinsic_cache_misses: u64,
465    last_layout_pruned_subtrees: u64,
466    last_layout_pruned_nodes: u64,
467    last_draw_ops: Duration,
468    last_draw_ops_culled_text_ops: u64,
469    last_paint: Duration,
470    last_paint_culled_ops: u64,
471    last_gpu_upload: Duration,
472    last_snapshot: Duration,
473    last_submit: Duration,
474    last_text_layout_cache_hits: u64,
475    last_text_layout_cache_misses: u64,
476    last_text_layout_cache_evictions: u64,
477    last_text_layout_shaped_bytes: u64,
478    /// Counts redraws actually rendered (not requested). Surfaced via
479    /// [`HostDiagnostics::frame_index`].
480    frame_index: u64,
481    /// Adapter backend tag (`"Vulkan"`, `"Metal"`, `"DX12"`, `"GL"`,
482    /// `"WebGPU"`). Captured once at adapter selection and surfaced in
483    /// the diagnostic overlay.
484    backend: &'static str,
485    /// Best-effort native clipboard. Initialization can fail in
486    /// display-less/headless environments; the host simply leaves copy
487    /// shortcuts as no-ops in that case.
488    clipboard: PlatformClipboard,
489    /// Last text mirrored into Linux's primary selection.
490    last_primary: String,
491}
492
493struct Gfx {
494    // Fields drop in declaration order. GPU resources must go before
495    // the device/window they were created from so shutdown tears them
496    // down before their owners disappear.
497    /// Negotiated color-management state surfaced to apps via
498    /// [`HostDiagnostics::color_management`]. `Unavailable` on hosts
499    /// where the protocol isn't present or the host short-circuited.
500    color_management: ColorManagementStatus,
501    /// The wgpu/WSI half of color negotiation — advertised surface
502    /// formats, chosen swapchain format, present/alpha mode, adapter.
503    /// Built once at surface creation; surfaced via
504    /// [`HostDiagnostics::surface_color`].
505    surface_color: damascene_core::SurfaceColorInfo,
506    renderer: Runner,
507    surface: wgpu::Surface<'static>,
508    queue: wgpu::Queue,
509    device: wgpu::Device,
510    window: Arc<Window>,
511    config: wgpu::SurfaceConfiguration,
512    /// Multisampled color attachment for the surface frame, kept in
513    /// sync with `config.width`/`config.height` and reallocated on
514    /// resize. The surface frame texture is the resolve target.
515    msaa: Option<MsaaTarget>,
516}
517
518fn surface_extent(config: &wgpu::SurfaceConfiguration) -> wgpu::Extent3d {
519    wgpu::Extent3d {
520        width: config.width,
521        height: config.height,
522        depth_or_array_layers: 1,
523    }
524}
525
526/// Conservative sRGB swapchain format — the universal fallback.
527fn srgb_format(caps: &wgpu::SurfaceCapabilities) -> wgpu::TextureFormat {
528    caps.formats
529        .iter()
530        .copied()
531        .find(|f| f.is_srgb())
532        .unwrap_or(caps.formats[0])
533}
534
535/// Extended-range float swapchain format for HDR output, if the surface
536/// offers it.
537///
538/// `Rgba16Float` is the one format wgpu's Vulkan backend pairs with
539/// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` (scRGB) — see
540/// `wgpu-hal/src/vulkan/{conv.rs,swapchain/native.rs}`. Configuring the
541/// surface with it yields a linear, extended-range swapchain that the WSI
542/// tags and the compositor encodes; our linear working-space values go out
543/// verbatim, with SDR content in `[0,1]` unchanged and `>1.0` emitting HDR.
544/// The WSI still owns the surface's color tag — we attach nothing.
545///
546/// `None` when the surface doesn't advertise it: an SDR output, a
547/// compositor without `extended_target_volume`, or no color management at
548/// all. Callers fall back to [`srgb_format`].
549#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
550fn wide_format(caps: &wgpu::SurfaceCapabilities) -> Option<wgpu::TextureFormat> {
551    caps.formats
552        .iter()
553        .copied()
554        .find(|f| *f == wgpu::TextureFormat::Rgba16Float)
555}
556
557/// Walk the app's color-space preference ladder and return the first
558/// `(swapchain format, renderer working space)` the host can actually
559/// deliver — the intersection of three sets: the app's *preferences* (the
560/// ladder), the *compositor's capabilities* (`caps.supports`), and *what
561/// the wgpu swapchain can carry* ([`deliver_space`]). Falls back to the
562/// 8-bit sRGB baseline, which any host can present.
563///
564/// This is the constrained form of
565/// [`damascene_core::color::ColorPreferences::negotiate`]: that method
566/// intersects only the first two sets and would over-promise, since a
567/// compositor may advertise PQ / BT.2020 while the wgpu swapchain can build
568/// only scRGB or sRGB. See docs/COLOR_MANAGEMENT.md.
569#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
570fn negotiate_output(
571    preferences: &ColorPreferences,
572    caps: &damascene_core::color::HostColorCapabilities,
573    surface_caps: &wgpu::SurfaceCapabilities,
574    targets: &damascene_core::color::CompositorColorTargets,
575) -> (wgpu::TextureFormat, damascene_core::color::ColorSpace) {
576    for &space in &preferences.working_spaces {
577        if caps.supports(space) {
578            if let Some(delivered) = deliver_space(space, surface_caps, targets) {
579                return delivered;
580            }
581        }
582    }
583    (
584        srgb_format(surface_caps),
585        damascene_core::color::ColorSpace::SRGB_LINEAR,
586    )
587}
588
589/// Map an agreed output color space to a concrete wgpu swapchain format +
590/// renderer working space, or `None` when the wgpu swapchain can't carry
591/// it. The working space is always linear; the swapchain format is what
592/// carries the encoding + dynamic range to the WSI.
593#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
594fn deliver_space(
595    space: damascene_core::color::ColorSpace,
596    surface_caps: &wgpu::SurfaceCapabilities,
597    targets: &damascene_core::color::CompositorColorTargets,
598) -> Option<(wgpu::TextureFormat, damascene_core::color::ColorSpace)> {
599    use damascene_core::color::{ColorSpace, Primaries, TransferFunction};
600    match (space.primaries, space.transfer) {
601        // Plain sRGB: an 8-bit sRGB-encoded swapchain; the GPU does the
602        // linear → sRGB encode on store. Always available.
603        (Primaries::Srgb, TransferFunction::Srgb) => {
604            Some((srgb_format(surface_caps), ColorSpace::SRGB_LINEAR))
605        }
606        // scRGB (== SRGB_LINEAR): linear sRGB primaries, extended range.
607        // wgpu carries this as an `Rgba16Float` swapchain tagged
608        // `EXTENDED_SRGB_LINEAR_EXT`. Deliverable only on a genuinely HDR
609        // output that offers the float format — on SDR we fall through to
610        // the cheaper 8-bit baseline (the extended range would only clamp).
611        (Primaries::Srgb, TransferFunction::Linear) => {
612            if targets.indicates_hdr() {
613                wide_format(surface_caps).map(|f| (f, ColorSpace::SRGB_LINEAR))
614            } else {
615                None
616            }
617        }
618        // Wider gamut (Display-P3, BT.2020) or HDR transfers (PQ / HLG): the
619        // wgpu Vulkan backend maps only the scRGB pair, so its swapchain
620        // can't carry these. Skipped — see docs/COLOR_MANAGEMENT.md.
621        _ => None,
622    }
623}
624
625/// Summarize the wgpu/WSI side of color negotiation for
626/// [`HostDiagnostics::surface_color`] — what the swapchain can represent,
627/// which is half of what the negotiator can pick (the compositor caps are
628/// the other half).
629fn build_surface_color_info(
630    adapter: &wgpu::Adapter,
631    surface_caps: &wgpu::SurfaceCapabilities,
632    chosen_format: wgpu::TextureFormat,
633    present_mode: wgpu::PresentMode,
634    alpha_mode: wgpu::CompositeAlphaMode,
635) -> damascene_core::SurfaceColorInfo {
636    let info = adapter.get_info();
637    let driver = match (info.driver.is_empty(), info.driver_info.is_empty()) {
638        (false, false) => format!("{} ({})", info.driver, info.driver_info),
639        (false, true) => info.driver.clone(),
640        (true, false) => info.driver_info.clone(),
641        (true, true) => String::new(),
642    };
643    damascene_core::SurfaceColorInfo {
644        adapter: info.name,
645        driver,
646        formats: surface_caps
647            .formats
648            .iter()
649            .map(|f| classify_surface_format(*f))
650            .collect(),
651        chosen_format: format!("{chosen_format:?}"),
652        present_mode: format!("{present_mode:?}"),
653        alpha_mode: format!("{alpha_mode:?}"),
654    }
655}
656
657/// Classify one surface format by how it can carry color output.
658fn classify_surface_format(f: wgpu::TextureFormat) -> damascene_core::SurfaceFormatInfo {
659    use wgpu::TextureFormat::{Rgb10a2Unorm, Rgba16Float, Rgba32Float};
660    damascene_core::SurfaceFormatInfo {
661        name: format!("{f:?}"),
662        srgb: f.is_srgb(),
663        // Float (linear-direct — the compositor encodes) or ≥10-bit (a
664        // PQ-encode target) can carry wide-gamut / HDR; 8-bit unorm is
665        // SDR-only.
666        wide: matches!(f, Rgba16Float | Rgba32Float | Rgb10a2Unorm),
667    }
668}
669
670/// Color setup for a freshly-created surface. We consult
671/// `wp_color_management_v1` for the compositor's capabilities and its
672/// preferred image description (for the Color Management showcase /
673/// `HostDiagnostics`), but we do **not** attach our own description.
674///
675/// Per the protocol a `wl_surface` has exactly one color-management owner,
676/// and for an accelerated client that owner is the WSI (Mesa), which tags
677/// the swapchain. A second `get_surface` raises a connection-fatal
678/// `surface_exists` error on the libwayland connection we share with
679/// winit/Mesa, crashing the app (seen on KDE with HDR enabled) — so we
680/// never attach. We *do* steer the WSI the compliant way: on a genuinely
681/// HDR output we select an `Rgba16Float` swapchain, which wgpu's Vulkan
682/// backend pairs with scRGB (`EXTENDED_SRGB_LINEAR_EXT`), letting `>1.0`
683/// reach the display. SDR outputs stay on the 8-bit sRGB baseline. See
684/// [`wide_format`] for the format mechanism and the color roadmap.
685///
686/// Linux + `wayland-color-management`: consults `wp_color_management_v1`.
687#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
688fn negotiate_color(
689    window: &Window,
690    preferences: &ColorPreferences,
691    surface_caps: &wgpu::SurfaceCapabilities,
692) -> ColorSetup {
693    use damascene_core::color::HostColorCapabilities;
694    use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle};
695
696    // Wayland raw handles — absent on X11 / other backends.
697    let handles = (
698        window.display_handle().ok().map(|h| h.as_raw()),
699        window.window_handle().ok().map(|h| h.as_raw()),
700    );
701    let (display_ptr, surface_ptr) = match handles {
702        (Some(RawDisplayHandle::Wayland(d)), Some(RawWindowHandle::Wayland(w))) => {
703            (d.display.as_ptr(), w.surface.as_ptr())
704        }
705        _ => return ColorSetup::srgb_unavailable(surface_caps),
706    };
707
708    let mgr = unsafe { wayland_color::WaylandColorManager::try_new(display_ptr, surface_ptr) };
709    let compositor_caps = mgr
710        .as_ref()
711        .map(|m| m.capabilities())
712        .unwrap_or_else(HostColorCapabilities::srgb_only);
713    let targets = mgr
714        .as_ref()
715        .map(|m| m.preferred_targets())
716        .unwrap_or_default();
717
718    // Negotiate the swapchain format + working space from the app's color
719    // preferences, the compositor's capabilities, and what the wgpu
720    // swapchain can actually carry. On a genuinely HDR output an app that
721    // asks for extended-range linear (scRGB) gets an `Rgba16Float`
722    // swapchain — wgpu tags it scRGB, the compositor encodes, our linear
723    // values go out verbatim (SDR ≤1.0 unchanged, >1.0 = HDR). We attach no
724    // description; the WSI owns the surface tag (compliant — float-format
725    // selection is a normal client knob, not a second `get_surface`). Apps
726    // that don't ask for HDR (the default `sdr_only`) stay on the cheaper
727    // 8-bit sRGB baseline. See docs/COLOR_MANAGEMENT.md.
728    let (format, working_space) =
729        negotiate_output(preferences, &compositor_caps, surface_caps, &targets);
730
731    // Diagnostic: DAMASCENE_COLOR_DEBUG=1 dumps the wgpu surface formats (what
732    // Mesa's WSI advertises), the compositor's reported state, and the
733    // swapchain format we settled on.
734    if std::env::var("DAMASCENE_COLOR_DEBUG").is_ok() {
735        eprintln!(
736            "damascene color: surface formats = {:?}",
737            surface_caps.formats
738        );
739        eprintln!(
740            "damascene color: compositor primaries={:?} transfers={:?} parametric={}",
741            compositor_caps.primaries,
742            compositor_caps.transfer_functions,
743            compositor_caps.parametric_creator(),
744        );
745        eprintln!(
746            "damascene color: preferred targets ref_white={:?} display_peak={:?} preferred_tf={:?} preferred_primaries={:?} indicates_hdr={}",
747            targets.reference_luminance_nits,
748            targets.target_max_luminance_nits,
749            targets.preferred_transfer,
750            targets.preferred_primaries,
751            targets.indicates_hdr(),
752        );
753        let wide = format == wgpu::TextureFormat::Rgba16Float;
754        eprintln!(
755            "damascene color: WSI owns surface color (no attach) — chose {format:?} ({})",
756            if wide {
757                "scRGB extended-range HDR"
758            } else {
759                "sRGB baseline"
760            },
761        );
762    }
763
764    // We never attach a description, so there is nothing for the compositor
765    // to interpret differently from the swapchain tag. We still report the
766    // protocol as Available (with the read-only targets) when the manager
767    // bound, so the showcase can inspect the host. The driver is data-only
768    // after `try_new`; `mgr` carries just the read-out capabilities +
769    // targets and is dropped at the end of this function. Nothing
770    // wayland-side outlives negotiation.
771    let status = if mgr.is_some() {
772        ColorManagementStatus::Available {
773            capabilities: compositor_caps,
774            attached: None,
775            targets,
776        }
777    } else {
778        ColorManagementStatus::Unavailable
779    };
780    // `working_space` comes from negotiation. Today every deliverable space
781    // is sRGB-primaries (sRGB or scRGB), so it resolves to `SRGB_LINEAR`
782    // either way — the swapchain format, not the working space, is what
783    // differs (8-bit sRGB HW-encoded vs fp16 extended-linear verbatim).
784    // Wider working spaces would flow through here once wgpu can deliver a
785    // wider-gamut swapchain to pair with them.
786    ColorSetup {
787        format,
788        working_space,
789        status,
790    }
791}
792
793/// Result of color negotiation for a surface.
794#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
795struct ColorSetup {
796    format: wgpu::TextureFormat,
797    working_space: damascene_core::color::ColorSpace,
798    status: ColorManagementStatus,
799}
800
801#[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
802impl ColorSetup {
803    fn srgb_unavailable(surface_caps: &wgpu::SurfaceCapabilities) -> Self {
804        Self {
805            format: srgb_format(surface_caps),
806            working_space: damascene_core::color::ColorSpace::SRGB_LINEAR,
807            status: ColorManagementStatus::Unavailable,
808        }
809    }
810}
811
812#[cfg(target_os = "android")]
813fn safe_area_for_window(window: &Window, surface_size: (u32, u32), scale_factor: f32) -> Sides {
814    let rect = window.content_rect();
815    if rect.right <= rect.left || rect.bottom <= rect.top || scale_factor <= 0.0 {
816        return Sides::default();
817    }
818    let (surface_w, surface_h) = (surface_size.0 as i32, surface_size.1 as i32);
819    Sides {
820        left: rect.left.max(0) as f32 / scale_factor,
821        top: rect.top.max(0) as f32 / scale_factor,
822        right: (surface_w - rect.right).max(0) as f32 / scale_factor,
823        bottom: (surface_h - rect.bottom).max(0) as f32 / scale_factor,
824    }
825}
826
827#[cfg(not(target_os = "android"))]
828fn safe_area_for_window(_window: &Window, _surface_size: (u32, u32), _scale_factor: f32) -> Sides {
829    Sides::default()
830}
831
832#[cfg(any(target_os = "android", target_os = "ios"))]
833fn sync_mobile_ime(window: &Window, renderer: &Runner, ime_allowed: &mut bool) {
834    let allowed = renderer.focused_captures_keys();
835    if allowed != *ime_allowed {
836        window.set_ime_allowed(allowed);
837        *ime_allowed = allowed;
838    }
839}
840
841impl<A: WinitWgpuApp> ApplicationHandler for Host<A> {
842    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
843        if self.gfx.is_some() {
844            return;
845        }
846        let attrs = Window::default_attributes()
847            .with_title(self.title)
848            .with_inner_size(PhysicalSize::new(
849                self.viewport.w as u32,
850                self.viewport.h as u32,
851            ));
852        #[cfg(target_os = "linux")]
853        let attrs = if let Some(app_id) = self.config.app_id.as_deref() {
854            // Fully-qualified — both extension traits define `with_name`.
855            use winit::platform::wayland::WindowAttributesExtWayland;
856            use winit::platform::x11::WindowAttributesExtX11;
857            let a = WindowAttributesExtWayland::with_name(attrs, app_id, "");
858            WindowAttributesExtX11::with_name(a, app_id, app_id)
859        } else {
860            attrs
861        };
862        let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
863
864        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
865        let surface = instance
866            .create_surface(window.clone())
867            .expect("create surface");
868
869        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
870            power_preference: wgpu::PowerPreference::default(),
871            compatible_surface: Some(&surface),
872            force_fallback_adapter: false,
873        }))
874        .expect("no compatible adapter");
875        self.backend = backend_label(adapter.get_info().backend);
876
877        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
878            label: Some("damascene_winit_wgpu::device"),
879            required_features: wgpu::Features::empty(),
880            required_limits: wgpu::Limits::default(),
881            experimental_features: wgpu::ExperimentalFeatures::default(),
882            memory_hints: wgpu::MemoryHints::Performance,
883            trace: wgpu::Trace::Off,
884        }))
885        .expect("request_device");
886
887        let size = window.inner_size();
888        let surface_caps = surface.get_capabilities(&adapter);
889
890        // Color negotiation: intersect the app's preferences with what
891        // the display server can color-manage and what the wgpu surface
892        // can represent, then attach the matching image description. The
893        // chosen `format` drives the swapchain; `working_space` drives
894        // the renderer; `color_management` is surfaced to apps via
895        // `HostDiagnostics`. Silent sRGB fallback on any mismatch.
896        #[cfg(all(target_os = "linux", feature = "wayland-color-management"))]
897        let (format, working_space, color_management) = {
898            let setup = negotiate_color(&window, &self.config.color_preferences, &surface_caps);
899            (setup.format, setup.working_space, setup.status)
900        };
901        #[cfg(not(all(target_os = "linux", feature = "wayland-color-management")))]
902        let (format, working_space, color_management) = (
903            srgb_format(&surface_caps),
904            damascene_core::color::ColorSpace::SRGB_LINEAR,
905            ColorManagementStatus::Unavailable,
906        );
907
908        // Pick a present mode. `Fifo` is the conservative default —
909        // mandatory in the wgpu spec, vsync-locked, predictable power
910        // cost. `low_latency_present` opts into `Mailbox` (with `Fifo`
911        // fallback) for apps where interaction latency matters more
912        // than steady-state throughput; see `HostConfig` for the
913        // rationale and trade-offs.
914        //
915        // `DAMASCENE_PRESENT_MODE=mailbox|immediate|fifo` overrides at
916        // runtime — useful for diagnosing without a recompile.
917        let mode_override = std::env::var("DAMASCENE_PRESENT_MODE").ok();
918        let prefer_mailbox =
919            self.config.low_latency_present || mode_override.as_deref() == Some("mailbox");
920        let prefer_immediate = mode_override.as_deref() == Some("immediate");
921        let prefer_fifo = mode_override.as_deref() == Some("fifo");
922        let present_mode = if prefer_immediate
923            && surface_caps
924                .present_modes
925                .contains(&wgpu::PresentMode::Immediate)
926        {
927            wgpu::PresentMode::Immediate
928        } else if prefer_mailbox
929            && !prefer_fifo
930            && surface_caps
931                .present_modes
932                .contains(&wgpu::PresentMode::Mailbox)
933        {
934            wgpu::PresentMode::Mailbox
935        } else if surface_caps
936            .present_modes
937            .contains(&wgpu::PresentMode::Fifo)
938        {
939            wgpu::PresentMode::Fifo
940        } else {
941            surface_caps.present_modes[0]
942        };
943        let config = wgpu::SurfaceConfiguration {
944            // COPY_SRC is required so backdrop-sampling shaders can
945            // copy the post-Pass-A surface into the runner's snapshot
946            // texture mid-frame. Cost is minimal — most surfaces
947            // already advertise it.
948            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
949            format,
950            width: size.width.max(1),
951            height: size.height.max(1),
952            present_mode,
953            alpha_mode: surface_caps.alpha_modes[0],
954            view_formats: vec![],
955            // Keep the in-flight queue shallow. With `Fifo` this is a
956            // hint that Mesa's WSI does not always honor — measured
957            // resize lag on Wayland was unaffected by changing this
958            // alone — but it's still the right default: an
959            // interactive UI gains nothing from buffering more than
960            // one frame ahead. Combined with `low_latency_present`
961            // (Mailbox), interactive cadence is bounded by render
962            // time, not by drained queue depth.
963            desired_maximum_frame_latency: 1,
964        };
965        surface.configure(&device, &config);
966
967        let sample_count = self.config.sample_count.max(1);
968        let mut renderer = Runner::with_sample_count(&device, &queue, format, sample_count);
969        renderer.set_theme(self.app.theme());
970        renderer.set_surface_size(config.width, config.height);
971        // Composite in the negotiated working space. For an sRGB
972        // swapchain this is SRGB_LINEAR (the GPU sRGB-encodes on store);
973        // for a float swapchain it's the wide-gamut linear space the
974        // surface holds verbatim.
975        renderer.set_working_color_space(working_space);
976        // Pre-rasterize printable ASCII for Inter + JetBrains Mono so
977        // first-frame appearance of new text labels (e.g. switching
978        // section in the showcase) doesn't trip a 20-30ms MSDF
979        // generation hitch. ~40ms one-off at startup.
980        renderer.warm_default_glyphs();
981        // Register any custom shaders the app declared. Done once at
982        // startup; pipelines are cached for the runner's lifetime.
983        for s in self.app.shaders() {
984            renderer.register_shader_with(
985                &device,
986                s.name,
987                s.wgsl,
988                s.samples_backdrop,
989                s.samples_time,
990            );
991        }
992
993        let msaa = (sample_count > 1)
994            .then(|| MsaaTarget::new(&device, format, surface_extent(&config), sample_count));
995
996        let surface_color = build_surface_color_info(
997            &adapter,
998            &surface_caps,
999            format,
1000            present_mode,
1001            config.alpha_mode,
1002        );
1003
1004        self.gfx = Some(Gfx {
1005            color_management,
1006            surface_color,
1007            renderer,
1008            surface,
1009            queue,
1010            device,
1011            window,
1012            config,
1013            msaa,
1014        });
1015        // Hand the app the device + queue so it can allocate any GPU
1016        // textures it intends to display via `surface()` widgets. Runs
1017        // whenever a host GPU context is created; on Android this can
1018        // happen again after Activity suspend/resume recreates the
1019        // native window.
1020        let gfx = self.gfx.as_ref().unwrap();
1021        self.app.gpu_setup(&gfx.device, &gfx.queue);
1022        self.next_periodic_redraw = self
1023            .config
1024            .redraw_interval
1025            .map(|interval| Instant::now() + interval);
1026        gfx.window.request_redraw();
1027    }
1028
1029    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
1030        #[cfg(target_os = "android")]
1031        {
1032            // Android destroys the native window while keeping the Rust
1033            // process alive. Any surface/window handles derived from
1034            // that native window must be dropped and recreated on the
1035            // next `resumed`, otherwise returning from Home can leave a
1036            // live process presenting to a dead surface.
1037            self.gfx.take();
1038            self.pending_resize = None;
1039            self.last_pointer = None;
1040            self.last_frame_at = None;
1041            self.next_periodic_redraw = None;
1042            self.ime_allowed = false;
1043        }
1044    }
1045
1046    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
1047        match event {
1048            WindowEvent::CloseRequested => {
1049                self.gfx.take();
1050                event_loop.exit();
1051            }
1052
1053            event => {
1054                let Some(gfx) = self.gfx.as_mut() else {
1055                    return;
1056                };
1057                let scale = gfx.window.scale_factor() as f32;
1058
1059                match event {
1060                    WindowEvent::Resized(size) => {
1061                        let w = size.width.max(1);
1062                        let h = size.height.max(1);
1063                        // Drop no-op resizes the compositor sometimes
1064                        // re-sends with the same dimensions — running
1065                        // surface.configure() for them just stalls the
1066                        // GPU pipeline without changing anything.
1067                        let already_pending = self
1068                            .pending_resize
1069                            .map(|s| s.width == w && s.height == h)
1070                            .unwrap_or(false);
1071                        let same_as_current = self.pending_resize.is_none()
1072                            && w == gfx.config.width
1073                            && h == gfx.config.height;
1074                        if already_pending || same_as_current {
1075                            return;
1076                        }
1077                        self.pending_resize = Some(PhysicalSize::new(w, h));
1078                        self.next_trigger = FrameTrigger::Resize;
1079                        gfx.window.request_redraw();
1080                    }
1081
1082                    WindowEvent::CursorMoved { position, .. } => {
1083                        let lx = position.x as f32 / scale;
1084                        let ly = position.y as f32 / scale;
1085                        self.last_pointer = Some((lx, ly));
1086                        let moved = gfx.renderer.pointer_moved(Pointer::moving(lx, ly));
1087                        for event in moved.events {
1088                            dispatch_app_event(
1089                                &mut self.app,
1090                                event,
1091                                &gfx.renderer,
1092                                &mut self.clipboard,
1093                                &mut self.last_primary,
1094                            );
1095                        }
1096                        // Wayland and most X11 compositors deliver
1097                        // CursorMoved at high frequency while the
1098                        // cursor is over the surface — only redraw
1099                        // when the move actually changed something
1100                        // (hovered identity, scrollbar drag, drag
1101                        // event), per `PointerMove`.
1102                        if moved.needs_redraw {
1103                            self.next_trigger = FrameTrigger::Pointer;
1104                            gfx.window.request_redraw();
1105                        }
1106                    }
1107
1108                    WindowEvent::CursorLeft { .. } => {
1109                        self.last_pointer = None;
1110                        for event in gfx.renderer.pointer_left() {
1111                            dispatch_app_event(
1112                                &mut self.app,
1113                                event,
1114                                &gfx.renderer,
1115                                &mut self.clipboard,
1116                                &mut self.last_primary,
1117                            );
1118                        }
1119                        self.next_trigger = FrameTrigger::Pointer;
1120                        gfx.window.request_redraw();
1121                    }
1122
1123                    WindowEvent::HoveredFile(path) => {
1124                        // File hover routes at the current pointer
1125                        // position; winit keeps firing CursorMoved
1126                        // alongside the file events so `last_pointer`
1127                        // tracks the drag in real time.
1128                        let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
1129                        for event in gfx.renderer.file_hovered(path, lx, ly) {
1130                            dispatch_app_event(
1131                                &mut self.app,
1132                                event,
1133                                &gfx.renderer,
1134                                &mut self.clipboard,
1135                                &mut self.last_primary,
1136                            );
1137                        }
1138                        self.next_trigger = FrameTrigger::Pointer;
1139                        gfx.window.request_redraw();
1140                    }
1141
1142                    WindowEvent::HoveredFileCancelled => {
1143                        for event in gfx.renderer.file_hover_cancelled() {
1144                            dispatch_app_event(
1145                                &mut self.app,
1146                                event,
1147                                &gfx.renderer,
1148                                &mut self.clipboard,
1149                                &mut self.last_primary,
1150                            );
1151                        }
1152                        self.next_trigger = FrameTrigger::Pointer;
1153                        gfx.window.request_redraw();
1154                    }
1155
1156                    WindowEvent::DroppedFile(path) => {
1157                        let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
1158                        for event in gfx.renderer.file_dropped(path, lx, ly) {
1159                            dispatch_app_event(
1160                                &mut self.app,
1161                                event,
1162                                &gfx.renderer,
1163                                &mut self.clipboard,
1164                                &mut self.last_primary,
1165                            );
1166                        }
1167                        self.next_trigger = FrameTrigger::Pointer;
1168                        gfx.window.request_redraw();
1169                    }
1170
1171                    WindowEvent::MouseInput { state, button, .. } => {
1172                        let Some(button) = pointer_button(button) else {
1173                            return;
1174                        };
1175                        let Some((lx, ly)) = self.last_pointer else {
1176                            return;
1177                        };
1178                        match state {
1179                            ElementState::Pressed => {
1180                                for event in
1181                                    gfx.renderer.pointer_down(Pointer::mouse(lx, ly, button))
1182                                {
1183                                    dispatch_app_event(
1184                                        &mut self.app,
1185                                        event,
1186                                        &gfx.renderer,
1187                                        &mut self.clipboard,
1188                                        &mut self.last_primary,
1189                                    );
1190                                }
1191                                #[cfg(any(target_os = "android", target_os = "ios"))]
1192                                sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
1193                                self.next_trigger = FrameTrigger::Pointer;
1194                                gfx.window.request_redraw();
1195                            }
1196                            ElementState::Released => {
1197                                for event in gfx.renderer.pointer_up(Pointer::mouse(lx, ly, button))
1198                                {
1199                                    let event =
1200                                        attach_primary_selection_text(event, &mut self.clipboard);
1201                                    dispatch_app_event(
1202                                        &mut self.app,
1203                                        event,
1204                                        &gfx.renderer,
1205                                        &mut self.clipboard,
1206                                        &mut self.last_primary,
1207                                    );
1208                                }
1209                                self.next_trigger = FrameTrigger::Pointer;
1210                                gfx.window.request_redraw();
1211                            }
1212                        }
1213                    }
1214
1215                    WindowEvent::MouseWheel { delta, .. } => {
1216                        let Some((lx, ly)) = self.last_pointer else {
1217                            return;
1218                        };
1219                        // Convert wheel ticks to logical pixels. Line-based
1220                        // deltas come from notched mouse wheels; pixel-based
1221                        // from trackpads. ~50 px/line matches typical OS feel.
1222                        let dy = match delta {
1223                            MouseScrollDelta::LineDelta(_, y) => -y * 50.0,
1224                            MouseScrollDelta::PixelDelta(p) => -(p.y as f32) / scale,
1225                        };
1226                        let mut needs_redraw = false;
1227                        let consumed = if let Some(event) =
1228                            gfx.renderer.pointer_wheel_event(lx, ly, 0.0, dy)
1229                        {
1230                            needs_redraw = true;
1231                            dispatch_app_wheel_event(
1232                                &mut self.app,
1233                                event,
1234                                &gfx.renderer,
1235                                &mut self.clipboard,
1236                                &mut self.last_primary,
1237                            )
1238                        } else {
1239                            false
1240                        };
1241                        if !consumed && gfx.renderer.pointer_wheel(lx, ly, dy) {
1242                            needs_redraw = true;
1243                        }
1244                        if needs_redraw {
1245                            self.next_trigger = FrameTrigger::Pointer;
1246                            gfx.window.request_redraw();
1247                        }
1248                    }
1249
1250                    WindowEvent::ModifiersChanged(modifiers) => {
1251                        self.modifiers = key_modifiers(modifiers.state());
1252                        gfx.renderer.set_modifiers(self.modifiers);
1253                    }
1254
1255                    WindowEvent::KeyboardInput {
1256                        event:
1257                            key_event @ winit::event::KeyEvent {
1258                                state: ElementState::Pressed,
1259                                ..
1260                            },
1261                        is_synthetic: false,
1262                        ..
1263                    } => {
1264                        if let Some(key) = map_key(&key_event.logical_key) {
1265                            for event in
1266                                gfx.renderer.key_down(key, self.modifiers, key_event.repeat)
1267                            {
1268                                match text_input::clipboard_request(&event) {
1269                                    Some(ClipboardKind::Copy) => {
1270                                        copy_current_selection(&gfx.renderer, &mut self.clipboard);
1271                                        dispatch_app_event(
1272                                            &mut self.app,
1273                                            event,
1274                                            &gfx.renderer,
1275                                            &mut self.clipboard,
1276                                            &mut self.last_primary,
1277                                        );
1278                                    }
1279                                    Some(ClipboardKind::Cut) => {
1280                                        copy_current_selection(&gfx.renderer, &mut self.clipboard);
1281                                        let delete = clipboard::delete_selection_event(event);
1282                                        dispatch_app_event(
1283                                            &mut self.app,
1284                                            delete,
1285                                            &gfx.renderer,
1286                                            &mut self.clipboard,
1287                                            &mut self.last_primary,
1288                                        );
1289                                    }
1290                                    Some(ClipboardKind::Paste) => {
1291                                        if let Some(paste) = paste_text_from_clipboard(
1292                                            event.clone(),
1293                                            &mut self.clipboard,
1294                                        ) {
1295                                            dispatch_app_event(
1296                                                &mut self.app,
1297                                                paste,
1298                                                &gfx.renderer,
1299                                                &mut self.clipboard,
1300                                                &mut self.last_primary,
1301                                            );
1302                                        } else {
1303                                            dispatch_app_event(
1304                                                &mut self.app,
1305                                                event,
1306                                                &gfx.renderer,
1307                                                &mut self.clipboard,
1308                                                &mut self.last_primary,
1309                                            );
1310                                        }
1311                                    }
1312                                    None => dispatch_app_event(
1313                                        &mut self.app,
1314                                        event,
1315                                        &gfx.renderer,
1316                                        &mut self.clipboard,
1317                                        &mut self.last_primary,
1318                                    ),
1319                                }
1320                            }
1321                        }
1322                        // Composed text payload (handles Shift+a → "A", dead
1323                        // keys, etc). winit attaches this on the same press
1324                        // event for non-IME input; IME composition arrives
1325                        // separately via `WindowEvent::Ime`.
1326                        if let Some(text) = &key_event.text
1327                            && let Some(event) = gfx.renderer.text_input(text.to_string())
1328                        {
1329                            dispatch_app_event(
1330                                &mut self.app,
1331                                event,
1332                                &gfx.renderer,
1333                                &mut self.clipboard,
1334                                &mut self.last_primary,
1335                            );
1336                        }
1337                        self.next_trigger = FrameTrigger::Keyboard;
1338                        gfx.window.request_redraw();
1339                    }
1340                    WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
1341                        if let Some(event) = gfx.renderer.text_input(text) {
1342                            dispatch_app_event(
1343                                &mut self.app,
1344                                event,
1345                                &gfx.renderer,
1346                                &mut self.clipboard,
1347                                &mut self.last_primary,
1348                            );
1349                        }
1350                        self.next_trigger = FrameTrigger::Keyboard;
1351                        gfx.window.request_redraw();
1352                    }
1353
1354                    WindowEvent::Touch(touch) => {
1355                        let lx = touch.location.x as f32 / scale;
1356                        let ly = touch.location.y as f32 / scale;
1357                        self.last_pointer = Some((lx, ly));
1358                        let mut pointer = Pointer::touch(
1359                            lx,
1360                            ly,
1361                            PointerButton::Primary,
1362                            damascene_core::PointerId(touch.id as u32),
1363                        );
1364                        pointer.pressure = touch_pressure(touch.force);
1365                        match touch.phase {
1366                            TouchPhase::Started => {
1367                                for event in gfx.renderer.pointer_down(pointer) {
1368                                    dispatch_app_event(
1369                                        &mut self.app,
1370                                        event,
1371                                        &gfx.renderer,
1372                                        &mut self.clipboard,
1373                                        &mut self.last_primary,
1374                                    );
1375                                }
1376                                #[cfg(any(target_os = "android", target_os = "ios"))]
1377                                sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
1378                            }
1379                            TouchPhase::Moved => {
1380                                let moved = gfx.renderer.pointer_moved(pointer);
1381                                for event in moved.events {
1382                                    dispatch_app_event(
1383                                        &mut self.app,
1384                                        event,
1385                                        &gfx.renderer,
1386                                        &mut self.clipboard,
1387                                        &mut self.last_primary,
1388                                    );
1389                                }
1390                                if !moved.needs_redraw {
1391                                    return;
1392                                }
1393                            }
1394                            TouchPhase::Ended => {
1395                                for event in gfx.renderer.pointer_up(pointer) {
1396                                    dispatch_app_event(
1397                                        &mut self.app,
1398                                        event,
1399                                        &gfx.renderer,
1400                                        &mut self.clipboard,
1401                                        &mut self.last_primary,
1402                                    );
1403                                }
1404                                self.last_pointer = None;
1405                            }
1406                            TouchPhase::Cancelled => {
1407                                for event in gfx.renderer.pointer_left() {
1408                                    dispatch_app_event(
1409                                        &mut self.app,
1410                                        event,
1411                                        &gfx.renderer,
1412                                        &mut self.clipboard,
1413                                        &mut self.last_primary,
1414                                    );
1415                                }
1416                                self.last_pointer = None;
1417                            }
1418                        }
1419                        self.next_trigger = FrameTrigger::Pointer;
1420                        gfx.window.request_redraw();
1421                    }
1422
1423                    WindowEvent::RedrawRequested => {
1424                        // Drain time-driven input events (touch
1425                        // long-press today) before this frame's
1426                        // build. The runtime folds the long-press
1427                        // deadline into `next_redraw_in`, so by the
1428                        // time RedrawRequested fires the deadline may
1429                        // have just elapsed; dispatching here ensures
1430                        // the synthesized LongPress event is visible
1431                        // to the App's `build` for this frame.
1432                        for event in gfx.renderer.poll_input(Instant::now()) {
1433                            self.app.on_event(event);
1434                        }
1435                        // Apply the latest coalesced resize, if any,
1436                        // before acquiring the next surface texture so
1437                        // the frame we render matches the size the
1438                        // compositor is asking for.
1439                        if let Some(size) = self.pending_resize.take() {
1440                            gfx.config.width = size.width;
1441                            gfx.config.height = size.height;
1442                            gfx.surface.configure(&gfx.device, &gfx.config);
1443                            gfx.renderer
1444                                .set_surface_size(gfx.config.width, gfx.config.height);
1445                            let extent = surface_extent(&gfx.config);
1446                            if let Some(msaa) = gfx.msaa.as_mut()
1447                                && !msaa.matches(extent)
1448                            {
1449                                *msaa = MsaaTarget::new(
1450                                    &gfx.device,
1451                                    gfx.config.format,
1452                                    extent,
1453                                    msaa.sample_count,
1454                                );
1455                            }
1456                        }
1457                        let frame = match gfx.surface.get_current_texture() {
1458                            wgpu::CurrentSurfaceTexture::Success(t)
1459                            | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
1460                            wgpu::CurrentSurfaceTexture::Lost
1461                            | wgpu::CurrentSurfaceTexture::Outdated => {
1462                                // Reconfigure and ask for another redraw —
1463                                // skipping `request_redraw` here would leave
1464                                // the compositor's stale frame on screen
1465                                // until some other event (resize, periodic
1466                                // tick, layout deadline) happened to wake
1467                                // us up, which is exactly the lag we're
1468                                // trying to avoid during an interactive
1469                                // drag on Wayland.
1470                                gfx.surface.configure(&gfx.device, &gfx.config);
1471                                gfx.window.request_redraw();
1472                                return;
1473                            }
1474                            other => {
1475                                eprintln!("surface unavailable: {other:?}");
1476                                return;
1477                            }
1478                        };
1479                        let view = frame
1480                            .texture
1481                            .create_view(&wgpu::TextureViewDescriptor::default());
1482
1483                        // Per-frame GPU update hook — apps writing to
1484                        // their own AppTextures (animated content,
1485                        // 3D viewports, video frames) push pixels to
1486                        // the queue here, before paint records draws
1487                        // that sample those textures.
1488                        // Snapshot diagnostics for this frame: trigger
1489                        // (consumed once — next defaults back to Other),
1490                        // wall-clock since previous frame, surface size,
1491                        // backend tag. Apps read this via `cx.diagnostics()`.
1492                        let frame_start = Instant::now();
1493                        let last_frame_dt = self
1494                            .last_frame_at
1495                            .map(|t| frame_start.duration_since(t))
1496                            .unwrap_or(Duration::ZERO);
1497                        self.last_frame_at = Some(frame_start);
1498                        let trigger = std::mem::take(&mut self.next_trigger);
1499                        let scale_factor = gfx.window.scale_factor() as f32;
1500                        let viewport = Rect::new(
1501                            0.0,
1502                            0.0,
1503                            gfx.config.width as f32 / scale_factor,
1504                            gfx.config.height as f32 / scale_factor,
1505                        );
1506                        // Paint-only path: a time-driven shader's deadline
1507                        // fired but no input / layout signal is queued for
1508                        // this frame, so we skip rebuild + layout and reuse
1509                        // the cached ops. `pending_resize` was applied above
1510                        // and would have set `Resize` instead — but defend
1511                        // against trigger-overwrite races by also requiring
1512                        // it to be empty here.
1513                        let paint_only =
1514                            trigger == FrameTrigger::ShaderPaint && self.pending_resize.is_none();
1515
1516                        let (prepare, palette, t_after_build, t_after_prepare) = if paint_only {
1517                            damascene_core::profile_span!("frame::repaint");
1518                            // No build pass on paint-only frames — reuse
1519                            // the renderer's already-set theme palette
1520                            // (set on the prior full prepare).
1521                            let palette = gfx.renderer.theme().palette().clone();
1522                            let t_after_build = Instant::now();
1523                            let prepare = gfx.renderer.repaint(
1524                                &gfx.device,
1525                                &gfx.queue,
1526                                viewport,
1527                                scale_factor,
1528                            );
1529                            let t_after_prepare = Instant::now();
1530                            (prepare, palette, t_after_build, t_after_prepare)
1531                        } else {
1532                            let msaa_samples =
1533                                gfx.msaa.as_ref().map(|m| m.sample_count).unwrap_or(1);
1534                            self.frame_index = self.frame_index.wrapping_add(1);
1535                            let diagnostics = HostDiagnostics {
1536                                backend: self.backend,
1537                                surface_size: (gfx.config.width, gfx.config.height),
1538                                scale_factor,
1539                                msaa_samples,
1540                                frame_index: self.frame_index,
1541                                last_frame_dt,
1542                                last_build: self.last_build,
1543                                last_prepare: self.last_prepare,
1544                                last_layout: self.last_layout,
1545                                last_layout_intrinsic_cache_hits: self
1546                                    .last_layout_intrinsic_cache_hits,
1547                                last_layout_intrinsic_cache_misses: self
1548                                    .last_layout_intrinsic_cache_misses,
1549                                last_layout_pruned_subtrees: self.last_layout_pruned_subtrees,
1550                                last_layout_pruned_nodes: self.last_layout_pruned_nodes,
1551                                last_draw_ops: self.last_draw_ops,
1552                                last_draw_ops_culled_text_ops: self.last_draw_ops_culled_text_ops,
1553                                last_paint: self.last_paint,
1554                                last_paint_culled_ops: self.last_paint_culled_ops,
1555                                last_gpu_upload: self.last_gpu_upload,
1556                                last_snapshot: self.last_snapshot,
1557                                last_submit: self.last_submit,
1558                                last_text_layout_cache_hits: self.last_text_layout_cache_hits,
1559                                last_text_layout_cache_misses: self.last_text_layout_cache_misses,
1560                                last_text_layout_cache_evictions: self
1561                                    .last_text_layout_cache_evictions,
1562                                last_text_layout_shaped_bytes: self.last_text_layout_shaped_bytes,
1563                                trigger,
1564                                working_color_space: gfx.renderer.working_color_space(),
1565                                color_management: gfx.color_management.clone(),
1566                                surface_color: Some(gfx.surface_color.clone()),
1567                            };
1568                            let (mut tree, palette) = {
1569                                damascene_core::profile_span!("frame::build");
1570                                self.app.before_paint(&gfx.queue);
1571                                WinitWgpuApp::before_build(&mut self.app);
1572                                let theme = self.app.theme();
1573                                let palette = theme.palette().clone();
1574                                let cx = damascene_core::BuildCx::new(&theme)
1575                                    .with_ui_state(gfx.renderer.ui_state())
1576                                    .with_diagnostics(&diagnostics)
1577                                    .with_viewport(viewport.w, viewport.h)
1578                                    .with_safe_area(safe_area_for_window(
1579                                        &gfx.window,
1580                                        (gfx.config.width, gfx.config.height),
1581                                        scale_factor,
1582                                    ));
1583                                let tree = self.app.build(&cx);
1584                                gfx.renderer.set_theme(theme);
1585                                gfx.renderer.set_hotkeys(self.app.hotkeys());
1586                                gfx.renderer.set_selection(self.app.selection());
1587                                gfx.renderer.push_toasts(self.app.drain_toasts());
1588                                gfx.renderer
1589                                    .push_focus_requests(self.app.drain_focus_requests());
1590                                gfx.renderer
1591                                    .push_scroll_requests(self.app.drain_scroll_requests());
1592                                for url in self.app.drain_link_opens() {
1593                                    #[cfg(target_os = "android")]
1594                                    open_link(&self.android_app, &url);
1595                                    #[cfg(not(any(target_os = "android", target_os = "ios")))]
1596                                    open_link(&url);
1597                                    #[cfg(target_os = "ios")]
1598                                    open_link(&url);
1599                                }
1600                                (tree, palette)
1601                            };
1602                            let t_after_build = Instant::now();
1603                            let prepare = {
1604                                damascene_core::profile_span!("frame::prepare");
1605                                gfx.renderer.prepare(
1606                                    &gfx.device,
1607                                    &gfx.queue,
1608                                    &mut tree,
1609                                    viewport,
1610                                    scale_factor,
1611                                )
1612                            };
1613                            #[cfg(any(target_os = "android", target_os = "ios"))]
1614                            sync_mobile_ime(&gfx.window, &gfx.renderer, &mut self.ime_allowed);
1615                            let t_after_prepare = Instant::now();
1616                            // Cursor resolution depends on the laid-out tree
1617                            // and the hovered key derived from layout ids,
1618                            // so it only updates on the full-prepare path.
1619                            // Paint-only frames inherit the previous cursor.
1620                            let cursor = gfx.renderer.ui_state().cursor(&tree);
1621                            if cursor != self.last_cursor {
1622                                gfx.window.set_cursor(winit_cursor(cursor));
1623                                self.last_cursor = cursor;
1624                            }
1625                            (prepare, palette, t_after_build, t_after_prepare)
1626                        };
1627
1628                        {
1629                            damascene_core::profile_span!("frame::submit");
1630                            let mut encoder = gfx.device.create_command_encoder(
1631                                &wgpu::CommandEncoderDescriptor {
1632                                    label: Some("damascene_winit_wgpu::encoder"),
1633                                },
1634                            );
1635                            // `render()` owns pass lifetimes itself so it can split
1636                            // around `BackdropSnapshot` boundaries when the app
1637                            // uses backdrop-sampling shaders. With no boundary it
1638                            // collapses to a single pass — same behaviour as the
1639                            // old `draw(pass)` path.
1640                            gfx.renderer.render(
1641                                &gfx.device,
1642                                &mut encoder,
1643                                &frame.texture,
1644                                &view,
1645                                gfx.msaa.as_ref().map(|msaa| &msaa.view),
1646                                wgpu::LoadOp::Clear(bg_color(&palette)),
1647                            );
1648                            gfx.queue.submit(Some(encoder.finish()));
1649                            frame.present();
1650                            let t_after_submit = Instant::now();
1651                            self.last_build = t_after_build - frame_start;
1652                            self.last_prepare = t_after_prepare - t_after_build;
1653                            self.last_submit = t_after_submit - t_after_prepare;
1654                            self.last_layout = prepare.timings.layout;
1655                            self.last_layout_intrinsic_cache_hits =
1656                                prepare.timings.layout_intrinsic_cache.hits;
1657                            self.last_layout_intrinsic_cache_misses =
1658                                prepare.timings.layout_intrinsic_cache.misses;
1659                            self.last_layout_pruned_subtrees =
1660                                prepare.timings.layout_prune.subtrees;
1661                            self.last_layout_pruned_nodes = prepare.timings.layout_prune.nodes;
1662                            self.last_draw_ops = prepare.timings.draw_ops;
1663                            self.last_draw_ops_culled_text_ops =
1664                                prepare.timings.draw_ops_culled_text_ops;
1665                            self.last_paint = prepare.timings.paint;
1666                            self.last_paint_culled_ops = prepare.timings.paint_culled_ops;
1667                            self.last_gpu_upload = prepare.timings.gpu_upload;
1668                            self.last_snapshot = prepare.timings.snapshot;
1669                            self.last_text_layout_cache_hits =
1670                                prepare.timings.text_layout_cache.hits;
1671                            self.last_text_layout_cache_misses =
1672                                prepare.timings.text_layout_cache.misses;
1673                            self.last_text_layout_cache_evictions =
1674                                prepare.timings.text_layout_cache.evictions;
1675                            self.last_text_layout_shaped_bytes =
1676                                prepare.timings.text_layout_cache.shaped_bytes;
1677                        }
1678
1679                        // Two-lane redraw scheduling: split widget /
1680                        // animation deadlines (require rebuild +
1681                        // layout) from time-driven shader deadlines
1682                        // (paint-only is sufficient). Each lane parks
1683                        // its own wake-up; `about_to_wait` chooses the
1684                        // earlier and `RedrawRequested` dispatches to
1685                        // either the full prepare path or the
1686                        // paint-only `repaint` path based on which
1687                        // deadline fired (input handlers naturally
1688                        // upgrade to full by overwriting the trigger).
1689                        //
1690                        // On a paint-only frame, only the paint lane
1691                        // is updated — `repaint` deliberately reports
1692                        // `next_layout_redraw_in = None` because it
1693                        // didn't re-evaluate that signal, so we leave
1694                        // the host's previously-parked layout
1695                        // deadline alone.
1696                        let now = Instant::now();
1697                        if !paint_only {
1698                            match prepare.next_layout_redraw_in {
1699                                None => self.next_layout_redraw = None,
1700                                Some(d) if d.is_zero() => {
1701                                    self.next_layout_redraw = None;
1702                                    self.next_trigger = FrameTrigger::Animation;
1703                                    gfx.window.request_redraw();
1704                                }
1705                                Some(d) => self.next_layout_redraw = Some(now + d),
1706                            }
1707                        }
1708                        match prepare.next_paint_redraw_in {
1709                            None => self.next_paint_redraw = None,
1710                            Some(d) if d.is_zero() => {
1711                                // Don't override an Animation trigger
1712                                // we already set above — layout takes
1713                                // precedence when both fire this turn.
1714                                self.next_paint_redraw = None;
1715                                if !matches!(self.next_trigger, FrameTrigger::Animation) {
1716                                    self.next_trigger = FrameTrigger::ShaderPaint;
1717                                }
1718                                gfx.window.request_redraw();
1719                            }
1720                            Some(d) => self.next_paint_redraw = Some(now + d),
1721                        }
1722                    }
1723                    _ => {}
1724                }
1725            }
1726        }
1727    }
1728
1729    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1730        let Some(gfx) = self.gfx.as_ref() else {
1731            event_loop.set_control_flow(ControlFlow::Wait);
1732            return;
1733        };
1734
1735        let now = Instant::now();
1736
1737        // Refresh the periodic-config wake-up. This is the legacy
1738        // host-config knob; with widgets adopting `redraw_within` it
1739        // becomes unnecessary, but keep it as a manual override for
1740        // hosts that want to force a cadence regardless of what the
1741        // tree asks.
1742        if let Some(interval) = self.config.redraw_interval {
1743            let next = self
1744                .next_periodic_redraw
1745                .get_or_insert_with(|| now + interval);
1746            if now >= *next {
1747                self.next_trigger = FrameTrigger::Periodic;
1748                gfx.window.request_redraw();
1749                *next = now + interval;
1750            }
1751        }
1752
1753        // Pick the earlier wake-up across all three sources: the
1754        // periodic-config knob, the layout deadline (rebuild + full
1755        // prepare), and the paint deadline (paint-only via repaint).
1756        // If a deadline has already passed, fire `request_redraw` and
1757        // clear it; the dispatcher in RedrawRequested reads the
1758        // trigger to decide layout vs paint-only path.
1759        let mut wake_up = self.next_periodic_redraw;
1760        if let Some(t) = self.next_layout_redraw {
1761            if now >= t {
1762                self.next_trigger = FrameTrigger::Animation;
1763                gfx.window.request_redraw();
1764                self.next_layout_redraw = None;
1765            } else {
1766                wake_up = Some(match wake_up {
1767                    Some(p) => p.min(t),
1768                    None => t,
1769                });
1770            }
1771        }
1772        if let Some(t) = self.next_paint_redraw {
1773            if now >= t {
1774                // Layout always wins: if a layout redraw is also queued
1775                // for this turn, take that path and let it re-derive
1776                // the paint deadline from the fresh prepare.
1777                if !matches!(self.next_trigger, FrameTrigger::Animation) {
1778                    self.next_trigger = FrameTrigger::ShaderPaint;
1779                }
1780                gfx.window.request_redraw();
1781                self.next_paint_redraw = None;
1782            } else {
1783                wake_up = Some(match wake_up {
1784                    Some(p) => p.min(t),
1785                    None => t,
1786                });
1787            }
1788        }
1789
1790        match wake_up {
1791            Some(t) => event_loop.set_control_flow(ControlFlow::WaitUntil(t)),
1792            None => event_loop.set_control_flow(ControlFlow::Wait),
1793        }
1794    }
1795}
1796
1797fn map_key(key: &Key) -> Option<UiKey> {
1798    match key {
1799        Key::Named(NamedKey::Enter) => Some(UiKey::Enter),
1800        Key::Named(NamedKey::Escape) => Some(UiKey::Escape),
1801        Key::Named(NamedKey::Tab) => Some(UiKey::Tab),
1802        Key::Named(NamedKey::Space) => Some(UiKey::Space),
1803        Key::Named(NamedKey::ArrowUp) => Some(UiKey::ArrowUp),
1804        Key::Named(NamedKey::ArrowDown) => Some(UiKey::ArrowDown),
1805        Key::Named(NamedKey::ArrowLeft) => Some(UiKey::ArrowLeft),
1806        Key::Named(NamedKey::ArrowRight) => Some(UiKey::ArrowRight),
1807        Key::Named(NamedKey::Backspace) => Some(UiKey::Backspace),
1808        Key::Named(NamedKey::Delete) => Some(UiKey::Delete),
1809        Key::Named(NamedKey::Home) => Some(UiKey::Home),
1810        Key::Named(NamedKey::End) => Some(UiKey::End),
1811        Key::Named(NamedKey::PageUp) => Some(UiKey::PageUp),
1812        Key::Named(NamedKey::PageDown) => Some(UiKey::PageDown),
1813        Key::Character(s) => Some(UiKey::Character(s.to_string())),
1814        Key::Named(named) => Some(UiKey::Other(format!("{named:?}"))),
1815        _ => None,
1816    }
1817}
1818
1819fn pointer_button(b: MouseButton) -> Option<PointerButton> {
1820    match b {
1821        MouseButton::Left => Some(PointerButton::Primary),
1822        MouseButton::Right => Some(PointerButton::Secondary),
1823        MouseButton::Middle => Some(PointerButton::Middle),
1824        // Back / Forward / Other → not surfaced; apps that need them can
1825        // grow the enum.
1826        _ => None,
1827    }
1828}
1829
1830#[cfg(not(any(target_os = "android", target_os = "ios")))]
1831fn new_clipboard() -> PlatformClipboard {
1832    arboard::Clipboard::new().ok()
1833}
1834
1835#[cfg(target_os = "ios")]
1836fn new_clipboard() -> PlatformClipboard {
1837    PlatformClipboard
1838}
1839
1840#[cfg(target_os = "android")]
1841fn new_clipboard(app: &AndroidApp) -> PlatformClipboard {
1842    PlatformClipboard { app: app.clone() }
1843}
1844
1845/// Open a URL surfaced by `App::drain_link_opens` through the OS's
1846/// default URL handler — `xdg-open` on Linux, `start` on Windows,
1847/// `open` on macOS — via the `open` crate. Failures (no handler
1848/// installed, sandboxed environment) are logged rather than panicking.
1849#[cfg(not(any(target_os = "android", target_os = "ios")))]
1850fn open_link(url: &str) {
1851    if let Err(err) = open::that_detached(url) {
1852        eprintln!("damascene-winit-wgpu: failed to open {url}: {err}");
1853    }
1854}
1855
1856#[cfg(target_os = "ios")]
1857fn open_link(url: &str) {
1858    eprintln!("damascene-winit-wgpu: opening links is not wired on iOS yet: {url}");
1859}
1860
1861#[cfg(target_os = "android")]
1862fn open_link(app: &AndroidApp, url: &str) {
1863    let app_for_thread = app.clone();
1864    let url = url.to_string();
1865    app.run_on_java_main_thread(Box::new(move || {
1866        let result = (|| -> jni::errors::Result<()> {
1867            let jvm = unsafe { jni::JavaVM::from_raw(app_for_thread.vm_as_ptr().cast()) };
1868            jvm.attach_current_thread(|env| {
1869                let url = env.new_string(&url)?;
1870                let uri = env
1871                    .call_static_method(
1872                        jni::jni_str!("android/net/Uri"),
1873                        jni::jni_str!("parse"),
1874                        jni::jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"),
1875                        &[jni::JValue::Object(url.as_ref())],
1876                    )?
1877                    .l()?;
1878                let action = env
1879                    .get_static_field(
1880                        jni::jni_str!("android/content/Intent"),
1881                        jni::jni_str!("ACTION_VIEW"),
1882                        jni::jni_sig!("Ljava/lang/String;"),
1883                    )?
1884                    .l()?;
1885                let intent = env.new_object(
1886                    jni::jni_str!("android/content/Intent"),
1887                    jni::jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"),
1888                    &[jni::JValue::Object(&action), jni::JValue::Object(&uri)],
1889                )?;
1890                let activity = unsafe {
1891                    jni::objects::JObject::from_raw(
1892                        env,
1893                        app_for_thread.activity_as_ptr() as jni::sys::jobject,
1894                    )
1895                };
1896                env.call_method(
1897                    &activity,
1898                    jni::jni_str!("startActivity"),
1899                    jni::jni_sig!("(Landroid/content/Intent;)V"),
1900                    &[jni::JValue::Object(&intent)],
1901                )?;
1902                Ok(())
1903            })
1904        })();
1905        if let Err(err) = result {
1906            eprintln!("damascene-winit-wgpu: failed to open link on Android: {err}");
1907        }
1908    }));
1909}
1910
1911fn touch_pressure(force: Option<Force>) -> Option<f32> {
1912    match force? {
1913        Force::Calibrated {
1914            force,
1915            max_possible_force,
1916            ..
1917        } if max_possible_force > 0.0 => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
1918        Force::Calibrated { force, .. } => Some(force.clamp(0.0, 1.0) as f32),
1919        Force::Normalized(v) => Some(v.clamp(0.0, 1.0) as f32),
1920    }
1921}
1922
1923/// Translate an Damascene [`Cursor`] to winit's [`CursorIcon`]. The Damascene
1924/// enum is a subset of winit's so this stays a 1:1 map; the wildcard
1925/// arm is a forward-compat safety net (Damascene's `Cursor` is
1926/// `non_exhaustive` — add a new variant in core, add the matching arm
1927/// here, otherwise it falls back to the platform default).
1928fn winit_cursor(cursor: Cursor) -> CursorIcon {
1929    match cursor {
1930        Cursor::Default => CursorIcon::Default,
1931        Cursor::Pointer => CursorIcon::Pointer,
1932        Cursor::Text => CursorIcon::Text,
1933        Cursor::NotAllowed => CursorIcon::NotAllowed,
1934        Cursor::Grab => CursorIcon::Grab,
1935        Cursor::Grabbing => CursorIcon::Grabbing,
1936        Cursor::Move => CursorIcon::Move,
1937        Cursor::EwResize => CursorIcon::EwResize,
1938        Cursor::NsResize => CursorIcon::NsResize,
1939        Cursor::NwseResize => CursorIcon::NwseResize,
1940        Cursor::NeswResize => CursorIcon::NeswResize,
1941        Cursor::ColResize => CursorIcon::ColResize,
1942        Cursor::RowResize => CursorIcon::RowResize,
1943        Cursor::Crosshair => CursorIcon::Crosshair,
1944        _ => CursorIcon::Default,
1945    }
1946}
1947
1948fn key_modifiers(mods: winit::keyboard::ModifiersState) -> KeyModifiers {
1949    KeyModifiers {
1950        shift: mods.shift_key(),
1951        ctrl: mods.control_key(),
1952        alt: mods.alt_key(),
1953        logo: mods.super_key(),
1954    }
1955}
1956
1957fn bg_color(palette: &damascene_core::Palette) -> wgpu::Color {
1958    let c = palette.background;
1959    wgpu::Color {
1960        r: srgb_to_linear(c.r as f64 / 255.0),
1961        g: srgb_to_linear(c.g as f64 / 255.0),
1962        b: srgb_to_linear(c.b as f64 / 255.0),
1963        a: c.a as f64 / 255.0,
1964    }
1965}
1966
1967fn copy_current_selection(renderer: &Runner, clipboard: &mut PlatformClipboard) {
1968    // Read the selection out of `last_tree` (via the runtime helper) —
1969    // see `RunnerCore::selected_text` for why a build-only path would
1970    // miss selections inside a virtual list.
1971    let Some(text) = renderer.selected_text() else {
1972        return;
1973    };
1974    set_clipboard_text(clipboard, text);
1975}
1976
1977fn dispatch_app_event<A: App>(
1978    app: &mut A,
1979    event: UiEvent,
1980    renderer: &Runner,
1981    clipboard: &mut PlatformClipboard,
1982    last_primary: &mut String,
1983) {
1984    let before = app.selection();
1985    app.on_event(event);
1986    if app.selection() != before {
1987        sync_primary_selection(&app.selection(), renderer, clipboard, last_primary);
1988    }
1989}
1990
1991fn dispatch_app_wheel_event<A: App>(
1992    app: &mut A,
1993    event: UiEvent,
1994    renderer: &Runner,
1995    clipboard: &mut PlatformClipboard,
1996    last_primary: &mut String,
1997) -> bool {
1998    let before = app.selection();
1999    let consumed = app.on_wheel_event(event);
2000    if app.selection() != before {
2001        sync_primary_selection(&app.selection(), renderer, clipboard, last_primary);
2002    }
2003    consumed
2004}
2005
2006fn sync_primary_selection(
2007    selection: &damascene_core::selection::Selection,
2008    renderer: &Runner,
2009    clipboard: &mut PlatformClipboard,
2010    last_primary: &mut String,
2011) {
2012    let text = renderer
2013        .selected_text_for(selection)
2014        .filter(|s| !s.is_empty())
2015        .unwrap_or_default();
2016    if text == *last_primary {
2017        return;
2018    }
2019    if !text.is_empty() {
2020        primary::set(clipboard, &text);
2021    }
2022    *last_primary = text;
2023}
2024
2025fn paste_text_from_clipboard(event: UiEvent, clipboard: &mut PlatformClipboard) -> Option<UiEvent> {
2026    let text = get_clipboard_text(clipboard)?;
2027    Some(clipboard::paste_text_event(event, text))
2028}
2029
2030fn attach_primary_selection_text(mut event: UiEvent, clipboard: &mut PlatformClipboard) -> UiEvent {
2031    if event.kind == UiEventKind::MiddleClick {
2032        event.text = primary::get(clipboard);
2033    }
2034    event
2035}
2036
2037#[cfg(not(any(target_os = "android", target_os = "ios")))]
2038fn set_clipboard_text(clipboard: &mut PlatformClipboard, text: String) {
2039    if let Some(cb) = clipboard {
2040        let _ = cb.set_text(text);
2041    }
2042}
2043
2044#[cfg(target_os = "ios")]
2045fn set_clipboard_text(_clipboard: &mut PlatformClipboard, _text: String) {}
2046
2047#[cfg(target_os = "android")]
2048fn set_clipboard_text(clipboard: &mut PlatformClipboard, text: String) {
2049    if let Err(err) = set_android_clipboard_text(&clipboard.app, &text) {
2050        eprintln!("damascene-winit-wgpu: failed to set Android clipboard: {err}");
2051    }
2052}
2053
2054#[cfg(not(any(target_os = "android", target_os = "ios")))]
2055fn get_clipboard_text(clipboard: &mut PlatformClipboard) -> Option<String> {
2056    clipboard.as_mut()?.get_text().ok()
2057}
2058
2059#[cfg(target_os = "ios")]
2060fn get_clipboard_text(_clipboard: &mut PlatformClipboard) -> Option<String> {
2061    None
2062}
2063
2064#[cfg(target_os = "android")]
2065fn get_clipboard_text(clipboard: &mut PlatformClipboard) -> Option<String> {
2066    match get_android_clipboard_text(&clipboard.app) {
2067        Ok(text) => text,
2068        Err(err) => {
2069            eprintln!("damascene-winit-wgpu: failed to read Android clipboard: {err}");
2070            None
2071        }
2072    }
2073}
2074
2075#[cfg(target_os = "android")]
2076fn set_android_clipboard_text(app: &AndroidApp, text: &str) -> jni::errors::Result<()> {
2077    use jni::refs::Reference as _;
2078
2079    let jvm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
2080    jvm.attach_current_thread(|env| {
2081        let activity = unsafe {
2082            jni::objects::JObject::from_raw(env, app.activity_as_ptr() as jni::sys::jobject)
2083        };
2084        let service_name = env.new_string("clipboard")?;
2085        let clipboard = env
2086            .call_method(
2087                &activity,
2088                jni::jni_str!("getSystemService"),
2089                jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
2090                &[jni::JValue::Object(service_name.as_ref())],
2091            )?
2092            .l()?;
2093        if clipboard.is_null() {
2094            return Ok(());
2095        }
2096
2097        let label = env.new_string("Damascene")?;
2098        let text = env.new_string(text)?;
2099        let clip = env
2100            .call_static_method(
2101                jni::jni_str!("android/content/ClipData"),
2102                jni::jni_str!("newPlainText"),
2103                jni::jni_sig!(
2104                    "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;"
2105                ),
2106                &[
2107                    jni::JValue::Object(label.as_ref()),
2108                    jni::JValue::Object(text.as_ref()),
2109                ],
2110            )?
2111            .l()?;
2112        env.call_method(
2113            &clipboard,
2114            jni::jni_str!("setPrimaryClip"),
2115            jni::jni_sig!("(Landroid/content/ClipData;)V"),
2116            &[jni::JValue::Object(&clip)],
2117        )?;
2118        Ok(())
2119    })
2120}
2121
2122#[cfg(target_os = "android")]
2123fn get_android_clipboard_text(app: &AndroidApp) -> jni::errors::Result<Option<String>> {
2124    use jni::refs::Reference as _;
2125
2126    let jvm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
2127    jvm.attach_current_thread(|env| {
2128        let activity = unsafe {
2129            jni::objects::JObject::from_raw(env, app.activity_as_ptr() as jni::sys::jobject)
2130        };
2131        let service_name = env.new_string("clipboard")?;
2132        let clipboard = env
2133            .call_method(
2134                &activity,
2135                jni::jni_str!("getSystemService"),
2136                jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
2137                &[jni::JValue::Object(service_name.as_ref())],
2138            )?
2139            .l()?;
2140        if clipboard.is_null() {
2141            return Ok(None);
2142        }
2143
2144        let clip = env
2145            .call_method(
2146                &clipboard,
2147                jni::jni_str!("getPrimaryClip"),
2148                jni::jni_sig!("()Landroid/content/ClipData;"),
2149                &[],
2150            )?
2151            .l()?;
2152        if clip.is_null() {
2153            return Ok(None);
2154        }
2155
2156        let item_count = env
2157            .call_method(
2158                &clip,
2159                jni::jni_str!("getItemCount"),
2160                jni::jni_sig!("()I"),
2161                &[],
2162            )?
2163            .i()?;
2164        if item_count <= 0 {
2165            return Ok(None);
2166        }
2167
2168        let item = env
2169            .call_method(
2170                &clip,
2171                jni::jni_str!("getItemAt"),
2172                jni::jni_sig!("(I)Landroid/content/ClipData$Item;"),
2173                &[jni::JValue::Int(0)],
2174            )?
2175            .l()?;
2176        if item.is_null() {
2177            return Ok(None);
2178        }
2179
2180        let text = env
2181            .call_method(
2182                &item,
2183                jni::jni_str!("coerceToText"),
2184                jni::jni_sig!("(Landroid/content/Context;)Ljava/lang/CharSequence;"),
2185                &[jni::JValue::Object(&activity)],
2186            )?
2187            .l()?;
2188        if text.is_null() {
2189            return Ok(None);
2190        }
2191
2192        let text = env
2193            .call_method(
2194                &text,
2195                jni::jni_str!("toString"),
2196                jni::jni_sig!("()Ljava/lang/String;"),
2197                &[],
2198            )?
2199            .l()?;
2200        if text.is_null() {
2201            return Ok(None);
2202        }
2203
2204        let text = env.cast_local::<jni::objects::JString>(text)?;
2205        Ok(Some(text.try_to_string(env)?))
2206    })
2207}
2208
2209mod primary {
2210    #[cfg(target_os = "linux")]
2211    pub fn set(clipboard: &mut super::PlatformClipboard, text: &str) {
2212        use arboard::{LinuxClipboardKind, SetExtLinux};
2213        if let Some(cb) = clipboard {
2214            let _ = cb.set().clipboard(LinuxClipboardKind::Primary).text(text);
2215        }
2216    }
2217
2218    #[cfg(target_os = "linux")]
2219    pub fn get(clipboard: &mut super::PlatformClipboard) -> Option<String> {
2220        use arboard::{GetExtLinux, LinuxClipboardKind};
2221        let cb = clipboard.as_mut()?;
2222        cb.get().clipboard(LinuxClipboardKind::Primary).text().ok()
2223    }
2224
2225    #[cfg(not(target_os = "linux"))]
2226    pub fn set(_clipboard: &mut super::PlatformClipboard, _text: &str) {}
2227
2228    #[cfg(not(target_os = "linux"))]
2229    pub fn get(_clipboard: &mut super::PlatformClipboard) -> Option<String> {
2230        None
2231    }
2232}
2233
2234/// Stable, human-readable tag for the wgpu backend in use. Surfaced to
2235/// apps via [`HostDiagnostics::backend`]; the showcase's debug overlay
2236/// renders this as-is. `BrowserWebGpu` is collapsed to `"WebGPU"` on
2237/// the assumption that browser-side telemetry already says "Chromium"
2238/// or "Firefox" elsewhere.
2239fn backend_label(backend: wgpu::Backend) -> &'static str {
2240    match backend {
2241        wgpu::Backend::Vulkan => "Vulkan",
2242        wgpu::Backend::Metal => "Metal",
2243        wgpu::Backend::Dx12 => "DX12",
2244        wgpu::Backend::Gl => "GL",
2245        wgpu::Backend::BrowserWebGpu => "WebGPU",
2246        wgpu::Backend::Noop => "noop",
2247    }
2248}
2249
2250/// Surface format is sRGB, but `wgpu::Color::Clear` is taken as
2251/// linear-space — convert so the clear color matches our token.
2252fn srgb_to_linear(c: f64) -> f64 {
2253    if c <= 0.04045 {
2254        c / 12.92
2255    } else {
2256        ((c + 0.055) / 1.055).powf(2.4)
2257    }
2258}
2259
2260#[cfg(test)]
2261mod tests {
2262    use super::*;
2263    use damascene_core::Selection;
2264    use damascene_core::SelectionPoint;
2265    use damascene_core::SelectionRange;
2266
2267    /// `BasicApp` is the wrapper the host uses around the user's app
2268    /// type. It must forward every per-frame App trait method to the
2269    /// inner type — a missing forward silently falls through to the
2270    /// trait default and the host loses sight of app state. A
2271    /// previous bug had `selection()` left out, which made the
2272    /// painter never receive a non-empty selection.
2273    #[test]
2274    fn basic_app_forwards_selection_to_inner() {
2275        struct AppWithSelection;
2276        impl App for AppWithSelection {
2277            fn build(&self, _cx: &damascene_core::BuildCx) -> damascene_core::El {
2278                damascene_core::widgets::text::text("hi")
2279            }
2280            fn selection(&self) -> Selection {
2281                Selection {
2282                    range: Some(SelectionRange {
2283                        anchor: SelectionPoint::new("p", 0),
2284                        head: SelectionPoint::new("p", 5),
2285                    }),
2286                }
2287            }
2288        }
2289        let basic = BasicApp(AppWithSelection);
2290        let sel = basic.selection();
2291        let r = sel.range.as_ref().expect("range forwarded through wrapper");
2292        assert_eq!(r.anchor.key, "p");
2293        assert_eq!(r.head.byte, 5);
2294    }
2295
2296    #[test]
2297    fn basic_app_forwards_wheel_events_to_inner() {
2298        struct AppWithWheel;
2299        impl App for AppWithWheel {
2300            fn build(&self, _cx: &damascene_core::BuildCx) -> damascene_core::El {
2301                damascene_core::widgets::text::text("hi")
2302            }
2303
2304            fn on_wheel_event(&mut self, event: damascene_core::UiEvent) -> bool {
2305                event.kind == UiEventKind::PointerWheel && event.wheel_dy() == Some(40.0)
2306            }
2307        }
2308
2309        let mut event = UiEvent::synthetic_click("wheel");
2310        event.kind = UiEventKind::PointerWheel;
2311        event.wheel_delta = Some((0.0, 40.0));
2312
2313        let mut basic = BasicApp(AppWithWheel);
2314        assert!(basic.on_wheel_event(event));
2315    }
2316}