Skip to main content

truce_gui/
platform.rs

1//! Platform window bridging for baseview.
2//!
3//! Bridges truce's `RawWindowHandle` to baseview's `HasRawWindowHandle`
4//! (raw-window-handle 0.5), and provides scale factor querying and
5//! wgpu surface creation.
6
7// `HasRawDisplayHandle` / `RwhRawDisplayHandle` are only touched on
8// the Linux (X11) arm of `HasRawWindowHandle for ParentWindow`;
9// silence the macOS/Windows dead-import warning.
10#[allow(unused_imports)]
11use raw_window_handle::{
12    HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle as RwhRawDisplayHandle,
13    RawWindowHandle as RwhRawWindowHandle,
14};
15use truce_core::editor::RawWindowHandle;
16
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20/// Newtype bridging truce's `RawWindowHandle` to baseview's
21/// `HasRawWindowHandle` (raw-window-handle 0.5).
22pub struct ParentWindow(pub RawWindowHandle);
23
24unsafe impl HasRawWindowHandle for ParentWindow {
25    fn raw_window_handle(&self) -> RwhRawWindowHandle {
26        match self.0 {
27            RawWindowHandle::AppKit(ptr) => {
28                let mut handle = raw_window_handle::AppKitWindowHandle::empty();
29                handle.ns_view = ptr;
30                RwhRawWindowHandle::AppKit(handle)
31            }
32            RawWindowHandle::UiKit(ptr) => {
33                // baseview doesn't host on iOS - the iOS editor
34                // path attaches a UIView directly without going
35                // through this bridge. We surface the handle for
36                // completeness (and so future iOS-aware backends
37                // can read it) but in practice no caller on iOS
38                // reaches this arm.
39                let mut handle = raw_window_handle::UiKitWindowHandle::empty();
40                handle.ui_view = ptr;
41                RwhRawWindowHandle::UiKit(handle)
42            }
43            RawWindowHandle::Win32(ptr) => {
44                let mut handle = raw_window_handle::Win32WindowHandle::empty();
45                handle.hwnd = ptr;
46                RwhRawWindowHandle::Win32(handle)
47            }
48            RawWindowHandle::X11(window_id) => {
49                let mut handle = raw_window_handle::XlibWindowHandle::empty();
50                // rwh 0.5 field type is c_ulong: u64 on Linux/macOS, u32 on Windows.
51                // The Windows narrowing is the lossy edge - `XID` is 32-bit there.
52                #[allow(clippy::cast_possible_truncation)]
53                {
54                    handle.window = window_id as _;
55                }
56                RwhRawWindowHandle::Xlib(handle)
57            }
58        }
59    }
60}
61
62/// Query the backing scale factor from the parent `NSView`'s window.
63#[cfg(target_os = "macos")]
64#[must_use]
65pub fn query_backing_scale(parent: &RawWindowHandle) -> f64 {
66    use objc::{msg_send, sel, sel_impl};
67
68    let ns_view_ptr = match parent {
69        RawWindowHandle::AppKit(ptr) => *ptr,
70        _ => return 1.0,
71    };
72
73    if ns_view_ptr.is_null() {
74        return 1.0;
75    }
76
77    unsafe {
78        let ns_view = ns_view_ptr.cast::<objc::runtime::Object>();
79        let window: *mut objc::runtime::Object = msg_send![ns_view, window];
80        let scale: f64 = if window.is_null() {
81            let screen: *mut objc::runtime::Object = msg_send![objc::class!(NSScreen), mainScreen];
82            if screen.is_null() {
83                2.0
84            } else {
85                msg_send![screen, backingScaleFactor]
86            }
87        } else {
88            msg_send![window, backingScaleFactor]
89        };
90        if scale < 1.0 { 1.0 } else { scale }
91    }
92}
93
94#[cfg(target_os = "windows")]
95#[must_use]
96pub fn query_backing_scale(parent: &RawWindowHandle) -> f64 {
97    let hwnd = match parent {
98        RawWindowHandle::Win32(ptr) => *ptr,
99        _ => return 1.0,
100    };
101    win32_dpi_scale(hwnd)
102}
103
104#[cfg(target_os = "linux")]
105#[must_use]
106pub fn query_backing_scale(_parent: &RawWindowHandle) -> f64 {
107    main_screen_scale()
108}
109
110#[cfg(target_os = "ios")]
111#[must_use]
112pub fn query_backing_scale(parent: &RawWindowHandle) -> f64 {
113    use objc2::msg_send;
114    use objc2::runtime::AnyObject;
115
116    let ui_view_ptr = match parent {
117        RawWindowHandle::UiKit(ptr) => *ptr,
118        _ => return 1.0,
119    };
120    if ui_view_ptr.is_null() {
121        return main_screen_scale();
122    }
123    // SAFETY: UIView is a UIKit class; `contentScaleFactor` is a
124    // public Objective-C property returning CGFloat (= f64 on
125    // arm64). Called on the main thread per UIKit's threading
126    // rule, which is also where AUv3 view controllers live.
127    unsafe {
128        let ui_view: *mut AnyObject = ui_view_ptr.cast();
129        let scale: f64 = msg_send![ui_view, contentScaleFactor];
130        if scale > 0.0 { scale } else { 1.0 }
131    }
132}
133
134#[cfg(target_os = "ios")]
135#[must_use]
136pub fn main_screen_scale() -> f64 {
137    use objc2::msg_send;
138    use objc2::runtime::{AnyClass, AnyObject};
139    // SAFETY: `+[UIScreen mainScreen]` is documented to return the
140    // process's primary screen on the main thread.
141    unsafe {
142        let Some(cls) = AnyClass::get(c"UIScreen") else {
143            return 1.0;
144        };
145        let screen: *mut AnyObject = msg_send![cls, mainScreen];
146        if screen.is_null() {
147            return 1.0;
148        }
149        let scale: f64 = msg_send![screen, scale];
150        if scale > 0.0 { scale } else { 1.0 }
151    }
152}
153
154/// Query the main screen's backing scale factor (no parent window needed).
155#[cfg(target_os = "macos")]
156#[must_use]
157pub fn main_screen_scale() -> f64 {
158    use objc::{msg_send, sel, sel_impl};
159    unsafe {
160        let screen: *mut objc::runtime::Object = msg_send![objc::class!(NSScreen), mainScreen];
161        if screen.is_null() {
162            1.0
163        } else {
164            let scale: f64 = msg_send![screen, backingScaleFactor];
165            if scale < 1.0 { 1.0 } else { scale }
166        }
167    }
168}
169
170#[cfg(target_os = "windows")]
171#[must_use]
172pub fn main_screen_scale() -> f64 {
173    win32_dpi_scale(std::ptr::null_mut())
174}
175
176// `reanchor_to_superview_top` and `reanchor_all_children_to_top`
177// live in the `truce-gui-utils` crate so backends that don't pull
178// `truce-gui` (vizia) can still get at them. Re-exported here for
179// existing `truce_gui::platform::...` call sites.
180pub use truce_gui_utils::{reanchor_all_children_to_top, reanchor_to_superview_top};
181
182/// Shared, mutable editor scale factor.
183///
184/// Single source of truth for the live content-scale of an open plugin
185/// window. Each GUI backend (egui / iced / slint) constructs one in
186/// `Editor::open`, stores it on the editor for `set_scale_factor` to
187/// write through, and hands a clone to its baseview `WindowHandler` so
188/// the render thread can pick up changes between frames.
189///
190/// Two writers, one reader-per-frame:
191/// - `Editor::set_scale_factor` (host → editor, e.g. CLAP `set_scale`,
192///   VST3 Windows `IPlugViewContentScaleSupport`).
193/// - `WindowEvent::Resized` (baseview → handler, fired when the OS
194///   reports a new content scale, e.g. dragging the window across
195///   monitors with different DPIs).
196///
197/// Most-recent-write wins. The handler tracks a `last_applied_scale`
198/// alongside its `EditorScale` clone and, when it observes a divergence
199/// at frame start, recomputes physical sizes and reconfigures its
200/// surface / renderer.
201#[derive(Clone)]
202pub struct EditorScale {
203    inner: Arc<AtomicU64>,
204}
205
206impl EditorScale {
207    /// Construct with an initial scale. Non-finite or non-positive
208    /// values clamp to 1.0 so callers never have to defend against
209    /// `0.0 * size` collapsing the surface.
210    #[must_use]
211    pub fn new(initial: f64) -> Self {
212        let v = if initial.is_finite() && initial > 0.0 {
213            initial
214        } else {
215            1.0
216        };
217        Self {
218            inner: Arc::new(AtomicU64::new(v.to_bits())),
219        }
220    }
221
222    /// Read the current scale.
223    #[must_use]
224    pub fn get(&self) -> f64 {
225        f64::from_bits(self.inner.load(Ordering::Relaxed))
226    }
227
228    /// Read the current scale, narrowed to `f32` for renderer / DSP
229    /// use. Display scales never exceed 4.0 in practice, so the f64
230    /// → f32 narrowing is invisible.
231    #[allow(clippy::cast_possible_truncation)]
232    #[must_use]
233    pub fn get_f32(&self) -> f32 {
234        self.get() as f32
235    }
236
237    /// Update the current scale. Non-finite or non-positive values are
238    /// silently dropped - callers are forwarding numbers from hosts /
239    /// `info.scale()` where a bad value is a host bug, not something
240    /// to propagate into the surface config.
241    pub fn set(&self, scale: f64) {
242        if scale.is_finite() && scale > 0.0 {
243            self.inner.store(scale.to_bits(), Ordering::Relaxed);
244        } else {
245            // Surface the upstream bug at least in debug builds so a
246            // host that's emitting bad scales doesn't get silently
247            // ignored. Production builds drop quietly to keep the
248            // editor running.
249            log::warn!(
250                "EditorScale::set ignored a bad value ({scale}); \
251                 expected finite, positive f64",
252            );
253        }
254    }
255
256    /// Pick up a host-driven scale change since the last frame.
257    ///
258    /// Reads the current scale (narrowed to `f32`) and compares it
259    /// bit-identically against `last`. When the value moved, updates
260    /// `last` and returns `Some(cur)`; otherwise returns `None`.
261    ///
262    /// Used by every editor backend's per-frame loop to gate surface /
263    /// renderer reconfiguration on actual host scale events. Bit-equality
264    /// is the correct semantics - the cell is written verbatim from
265    /// host callbacks, never through accumulating arithmetic, so an
266    /// epsilon-based check would either thrash on noise (there is
267    /// none) or miss a legitimate `1.0 → 1.0001` host signal.
268    #[allow(clippy::cast_possible_truncation, clippy::float_cmp)]
269    pub fn take_change(&self, last: &mut f32) -> Option<f32> {
270        let cur = self.get() as f32;
271        if cur == *last {
272            None
273        } else {
274            *last = cur;
275            Some(cur)
276        }
277    }
278}
279
280/// Convert a logical extent (in points) to physical pixels.
281///
282/// Standardised rounding policy across every truce GUI backend:
283/// round to nearest, then clamp the result to `1` so a degenerate
284/// `0 × scale` doesn't collapse a wgpu surface (`width: 0` is a
285/// validation error). The `logical.max(1)` guard handles the
286/// converse - a zero-logical caller can't multiply through to `0`
287/// before the round.
288///
289/// Canonical definition lives in `truce-gui-types` so `truce-gpu`'s
290/// `WgpuBackend` can call it without a `truce-gui` dep (cycle); the
291/// re-export below preserves the historical `truce_gui::to_physical_px`
292/// path.
293pub use truce_gui_types::to_physical_px;
294
295/// Cached display scale factor on Linux, stored as f64 bits. Zero means unset.
296///
297/// Linux has no safe synchronous DPI query from plugin code - the authoritative
298/// value is read by baseview internally (from `Xft.dpi` with a screen-geometry
299/// fallback) and delivered via `WindowEvent::Resized::info.scale()` once the
300/// window is live. We cache the first value an editor sees there so that later
301/// pre-window `main_screen_scale()` calls (e.g. the next editor's `::new`)
302/// return something useful instead of 1.0.
303#[cfg(target_os = "linux")]
304static LINUX_SCALE_BITS: AtomicU64 = AtomicU64::new(0);
305
306/// Record the display scale factor observed from baseview on Linux. Editors
307/// should call this from their `WindowEvent::Resized` handlers so subsequent
308/// pre-window queries match what baseview is delivering. No-op on non-Linux.
309pub fn note_linux_scale_factor(scale: f64) {
310    #[cfg(target_os = "linux")]
311    {
312        if scale.is_finite() && scale > 0.0 {
313            LINUX_SCALE_BITS.store(scale.to_bits(), Ordering::Relaxed);
314        }
315    }
316    #[cfg(not(target_os = "linux"))]
317    {
318        let _ = scale;
319    }
320}
321
322#[cfg(target_os = "linux")]
323pub fn main_screen_scale() -> f64 {
324    // Priority: TRUCE_SCALE env var (dev/test override) → cached scale
325    // observed from baseview → 1.0 fallback. No side-channel Xlib calls -
326    // those crashed inside NVIDIA's Vulkan driver when invoked from the
327    // render thread.
328    if let Ok(s) = std::env::var("TRUCE_SCALE")
329        && let Ok(v) = s.parse::<f64>()
330        && v.is_finite()
331        && v > 0.0
332    {
333        return v;
334    }
335    let bits = LINUX_SCALE_BITS.load(Ordering::Relaxed);
336    if bits == 0 {
337        return 1.0;
338    }
339    let v = f64::from_bits(bits);
340    if v.is_finite() && v > 0.0 { v } else { 1.0 }
341}
342
343/// Query the DPI scale factor on Windows.
344/// If `hwnd` is non-null, queries per-window DPI; otherwise queries the system DPI.
345#[cfg(target_os = "windows")]
346fn win32_dpi_scale(hwnd: *mut std::ffi::c_void) -> f64 {
347    // Default DPI is 96; scale = actual_dpi / 96.
348    const DEFAULT_DPI: u32 = 96;
349
350    unsafe extern "system" {
351        fn GetDpiForWindow(hwnd: *mut std::ffi::c_void) -> u32;
352        fn GetDpiForSystem() -> u32;
353    }
354
355    let dpi = if hwnd.is_null() {
356        unsafe { GetDpiForSystem() }
357    } else {
358        let d = unsafe { GetDpiForWindow(hwnd) };
359        if d == 0 {
360            unsafe { GetDpiForSystem() }
361        } else {
362            d
363        }
364    };
365
366    if dpi == 0 {
367        1.0
368    } else {
369        f64::from(dpi) / f64::from(DEFAULT_DPI)
370    }
371}
372
373#[cfg(target_os = "windows")]
374fn current_module_hinstance() -> Option<std::num::NonZeroIsize> {
375    unsafe extern "system" {
376        fn GetModuleHandleW(lpModuleName: *const u16) -> isize;
377    }
378    // SAFETY: `GetModuleHandleW(NULL)` is documented to return the running
379    // EXE's HMODULE without acquiring a refcount; no threading or aliasing
380    // concerns. Returns 0 only in pathological cases (kernel32 missing).
381    let hmodule = unsafe { GetModuleHandleW(std::ptr::null()) };
382    std::num::NonZeroIsize::new(hmodule)
383}
384
385/// wgpu backends to use for an editor that presents into a
386/// host-owned child window. macOS is Metal-only; Windows is DX12
387/// (the only backend feature truce-gui/truce-gpu compile in on
388/// Windows - see their `Cargo.toml`); Linux keeps `PRIMARY`.
389#[must_use]
390pub fn editor_wgpu_backends() -> wgpu::Backends {
391    #[cfg(target_os = "windows")]
392    {
393        wgpu::Backends::DX12
394    }
395    #[cfg(target_os = "macos")]
396    {
397        wgpu::Backends::METAL
398    }
399    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
400    {
401        wgpu::Backends::PRIMARY
402    }
403}
404
405/// `wgpu::InstanceDescriptor` for editor surfaces, with the DX12
406/// shader compiler pinned to **FXC**.
407///
408/// wgpu 29 defaults DX12 to a dynamically-loaded **DXC**
409/// (`dxcompiler.dll`). When a host process has already loaded its own
410/// incompatible `dxcompiler.dll` - Pro Tools does - wgpu's
411/// `DxcCreateInstance` returns `E_NOINTERFACE` and the *entire* DX12
412/// backend fails to initialise, leaving the instance with zero
413/// adapters: a blank editor (egui / built-in) or a panic on the
414/// `.expect` (slint). FXC (`d3dcompiler_47.dll`, always present on
415/// Windows, never conflicts) sidesteps it. wgpu 0.19 (iced)
416/// defaulted to FXC, which is why iced was never affected.
417#[must_use]
418pub fn editor_instance_descriptor() -> wgpu::InstanceDescriptor {
419    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
420    desc.backends = editor_wgpu_backends();
421    desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
422    desc
423}
424
425/// Bridge a baseview raw-window-handle 0.5 to a wgpu-compatible
426/// `SurfaceTargetUnsafe` using rwh 0.6 types.
427///
428/// Both `truce-gui`'s blit pipeline (cpu mode) and
429/// `truce_gpu::WgpuBackend::from_window` (gpu mode, used by
430/// `GpuEditor`) need this bridge; the two crates can't share a
431/// canonical copy without forming a dep cycle, so each carries its
432/// own ~100 LOC version. The two are kept in sync by inspection.
433///
434/// # Safety
435/// The window handle must be valid for the lifetime of the returned surface.
436#[cfg(not(target_os = "ios"))]
437#[must_use]
438pub unsafe fn create_wgpu_surface(
439    instance: &wgpu::Instance,
440    window: &baseview::Window,
441) -> Option<wgpu::Surface<'static>> {
442    unsafe {
443        let rwh = window.raw_window_handle();
444        let surface_target = match rwh {
445            #[cfg(target_os = "macos")]
446            RwhRawWindowHandle::AppKit(handle) => {
447                let ns_view = handle.ns_view;
448                if ns_view.is_null() {
449                    return None;
450                }
451                let rwh6_window = wgpu::rwh::RawWindowHandle::AppKit(
452                    wgpu::rwh::AppKitWindowHandle::new(std::ptr::NonNull::new(ns_view)?),
453                );
454                let rwh6_display =
455                    wgpu::rwh::RawDisplayHandle::AppKit(wgpu::rwh::AppKitDisplayHandle::new());
456                wgpu::SurfaceTargetUnsafe::RawHandle {
457                    raw_display_handle: Some(rwh6_display),
458                    raw_window_handle: rwh6_window,
459                }
460            }
461            #[cfg(target_os = "windows")]
462            RwhRawWindowHandle::Win32(handle) => {
463                let hwnd = handle.hwnd;
464                if hwnd.is_null() {
465                    return None;
466                }
467                let mut win32 =
468                    wgpu::rwh::Win32WindowHandle::new(std::num::NonZeroIsize::new(hwnd as isize)?);
469                // wgpu's Vulkan backend requires `hinstance` to be set
470                // (`vkCreateWin32SurfaceKHR` rejects a null HINSTANCE).
471                // baseview leaves the rwh 0.5 `hinstance` field at null,
472                // so populate it here with the running module's HMODULE.
473                win32.hinstance = current_module_hinstance();
474                let rwh6_window = wgpu::rwh::RawWindowHandle::Win32(win32);
475                let rwh6_display =
476                    wgpu::rwh::RawDisplayHandle::Windows(wgpu::rwh::WindowsDisplayHandle::new());
477                wgpu::SurfaceTargetUnsafe::RawHandle {
478                    raw_display_handle: Some(rwh6_display),
479                    raw_window_handle: rwh6_window,
480                }
481            }
482            #[cfg(target_os = "linux")]
483            RwhRawWindowHandle::Xlib(handle) => {
484                let RwhRawDisplayHandle::Xlib(display_handle) = window.raw_display_handle() else {
485                    return None;
486                };
487                let display_ptr = std::ptr::NonNull::new(display_handle.display);
488                let rwh6_window = wgpu::rwh::RawWindowHandle::Xlib(
489                    wgpu::rwh::XlibWindowHandle::new(handle.window),
490                );
491                let rwh6_display = wgpu::rwh::RawDisplayHandle::Xlib(
492                    wgpu::rwh::XlibDisplayHandle::new(display_ptr, display_handle.screen),
493                );
494                wgpu::SurfaceTargetUnsafe::RawHandle {
495                    raw_display_handle: Some(rwh6_display),
496                    raw_window_handle: rwh6_window,
497                }
498            }
499            _ => return None,
500        };
501
502        instance.create_surface_unsafe(surface_target).ok()
503    }
504}