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/// Shared, mutable editor scale factor.
177///
178/// Single source of truth for the live content-scale of an open plugin
179/// window. Each GUI backend (egui / iced / slint) constructs one in
180/// `Editor::open`, stores it on the editor for `set_scale_factor` to
181/// write through, and hands a clone to its baseview `WindowHandler` so
182/// the render thread can pick up changes between frames.
183///
184/// Two writers, one reader-per-frame:
185/// - `Editor::set_scale_factor` (host → editor, e.g. CLAP `set_scale`,
186/// VST3 Windows `IPlugViewContentScaleSupport`).
187/// - `WindowEvent::Resized` (baseview → handler, fired when the OS
188/// reports a new content scale, e.g. dragging the window across
189/// monitors with different DPIs).
190///
191/// Most-recent-write wins. The handler tracks a `last_applied_scale`
192/// alongside its `EditorScale` clone and, when it observes a divergence
193/// at frame start, recomputes physical sizes and reconfigures its
194/// surface / renderer.
195#[derive(Clone)]
196pub struct EditorScale {
197 inner: Arc<AtomicU64>,
198}
199
200impl EditorScale {
201 /// Construct with an initial scale. Non-finite or non-positive
202 /// values clamp to 1.0 so callers never have to defend against
203 /// `0.0 * size` collapsing the surface.
204 #[must_use]
205 pub fn new(initial: f64) -> Self {
206 let v = if initial.is_finite() && initial > 0.0 {
207 initial
208 } else {
209 1.0
210 };
211 Self {
212 inner: Arc::new(AtomicU64::new(v.to_bits())),
213 }
214 }
215
216 /// Read the current scale.
217 #[must_use]
218 pub fn get(&self) -> f64 {
219 f64::from_bits(self.inner.load(Ordering::Relaxed))
220 }
221
222 /// Read the current scale, narrowed to `f32` for renderer / DSP
223 /// use. Display scales never exceed 4.0 in practice, so the f64
224 /// → f32 narrowing is invisible.
225 #[allow(clippy::cast_possible_truncation)]
226 #[must_use]
227 pub fn get_f32(&self) -> f32 {
228 self.get() as f32
229 }
230
231 /// Update the current scale. Non-finite or non-positive values are
232 /// silently dropped - callers are forwarding numbers from hosts /
233 /// `info.scale()` where a bad value is a host bug, not something
234 /// to propagate into the surface config.
235 pub fn set(&self, scale: f64) {
236 if scale.is_finite() && scale > 0.0 {
237 self.inner.store(scale.to_bits(), Ordering::Relaxed);
238 } else {
239 // Surface the upstream bug at least in debug builds so a
240 // host that's emitting bad scales doesn't get silently
241 // ignored. Production builds drop quietly to keep the
242 // editor running.
243 log::warn!(
244 "EditorScale::set ignored a bad value ({scale}); \
245 expected finite, positive f64",
246 );
247 }
248 }
249
250 /// Pick up a host-driven scale change since the last frame.
251 ///
252 /// Reads the current scale (narrowed to `f32`) and compares it
253 /// bit-identically against `last`. When the value moved, updates
254 /// `last` and returns `Some(cur)`; otherwise returns `None`.
255 ///
256 /// Used by every editor backend's per-frame loop to gate surface /
257 /// renderer reconfiguration on actual host scale events. Bit-equality
258 /// is the correct semantics - the cell is written verbatim from
259 /// host callbacks, never through accumulating arithmetic, so an
260 /// epsilon-based check would either thrash on noise (there is
261 /// none) or miss a legitimate `1.0 → 1.0001` host signal.
262 #[allow(clippy::cast_possible_truncation, clippy::float_cmp)]
263 pub fn take_change(&self, last: &mut f32) -> Option<f32> {
264 let cur = self.get() as f32;
265 if cur == *last {
266 None
267 } else {
268 *last = cur;
269 Some(cur)
270 }
271 }
272}
273
274/// Convert a logical extent (in points) to physical pixels.
275///
276/// Standardised rounding policy across every truce GUI backend:
277/// round to nearest, then clamp the result to `1` so a degenerate
278/// `0 × scale` doesn't collapse a wgpu surface (`width: 0` is a
279/// validation error). The `logical.max(1)` guard handles the
280/// converse - a zero-logical caller can't multiply through to `0`
281/// before the round.
282///
283/// Replaces a mix of truncating `(logical * scale) as u32` casts,
284/// `.round() as u32` without a min clamp, and the explicit
285/// `.round().max(1.0) as u32` form that landed in
286/// `truce-gui::backend_cpu` first. One helper, every site, identical
287/// pixel maths.
288// Logical pixel sizes are bounded by `u32::MAX / scale`; in practice
289// no editor exceeds 16384 logical pixels.
290#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
291#[inline]
292#[must_use]
293pub fn to_physical_px(logical: u32, scale: f64) -> u32 {
294 (f64::from(logical.max(1)) * scale).round().max(1.0) as u32
295}
296
297/// Cached display scale factor on Linux, stored as f64 bits. Zero means unset.
298///
299/// Linux has no safe synchronous DPI query from plugin code - the authoritative
300/// value is read by baseview internally (from `Xft.dpi` with a screen-geometry
301/// fallback) and delivered via `WindowEvent::Resized::info.scale()` once the
302/// window is live. We cache the first value an editor sees there so that later
303/// pre-window `main_screen_scale()` calls (e.g. the next editor's `::new`)
304/// return something useful instead of 1.0.
305#[cfg(target_os = "linux")]
306static LINUX_SCALE_BITS: AtomicU64 = AtomicU64::new(0);
307
308/// Record the display scale factor observed from baseview on Linux. Editors
309/// should call this from their `WindowEvent::Resized` handlers so subsequent
310/// pre-window queries match what baseview is delivering. No-op on non-Linux.
311pub fn note_linux_scale_factor(scale: f64) {
312 #[cfg(target_os = "linux")]
313 {
314 if scale.is_finite() && scale > 0.0 {
315 LINUX_SCALE_BITS.store(scale.to_bits(), Ordering::Relaxed);
316 }
317 }
318 #[cfg(not(target_os = "linux"))]
319 {
320 let _ = scale;
321 }
322}
323
324#[cfg(target_os = "linux")]
325pub fn main_screen_scale() -> f64 {
326 // Priority: TRUCE_SCALE env var (dev/test override) → cached scale
327 // observed from baseview → 1.0 fallback. No side-channel Xlib calls -
328 // those crashed inside NVIDIA's Vulkan driver when invoked from the
329 // render thread.
330 if let Ok(s) = std::env::var("TRUCE_SCALE")
331 && let Ok(v) = s.parse::<f64>()
332 && v.is_finite()
333 && v > 0.0
334 {
335 return v;
336 }
337 let bits = LINUX_SCALE_BITS.load(Ordering::Relaxed);
338 if bits == 0 {
339 return 1.0;
340 }
341 let v = f64::from_bits(bits);
342 if v.is_finite() && v > 0.0 { v } else { 1.0 }
343}
344
345/// Query the DPI scale factor on Windows.
346/// If `hwnd` is non-null, queries per-window DPI; otherwise queries the system DPI.
347#[cfg(target_os = "windows")]
348fn win32_dpi_scale(hwnd: *mut std::ffi::c_void) -> f64 {
349 // Default DPI is 96; scale = actual_dpi / 96.
350 const DEFAULT_DPI: u32 = 96;
351
352 unsafe extern "system" {
353 fn GetDpiForWindow(hwnd: *mut std::ffi::c_void) -> u32;
354 fn GetDpiForSystem() -> u32;
355 }
356
357 let dpi = if hwnd.is_null() {
358 unsafe { GetDpiForSystem() }
359 } else {
360 let d = unsafe { GetDpiForWindow(hwnd) };
361 if d == 0 {
362 unsafe { GetDpiForSystem() }
363 } else {
364 d
365 }
366 };
367
368 if dpi == 0 {
369 1.0
370 } else {
371 f64::from(dpi) / f64::from(DEFAULT_DPI)
372 }
373}
374
375#[cfg(target_os = "windows")]
376fn current_module_hinstance() -> Option<std::num::NonZeroIsize> {
377 unsafe extern "system" {
378 fn GetModuleHandleW(lpModuleName: *const u16) -> isize;
379 }
380 // SAFETY: `GetModuleHandleW(NULL)` is documented to return the running
381 // EXE's HMODULE without acquiring a refcount; no threading or aliasing
382 // concerns. Returns 0 only in pathological cases (kernel32 missing).
383 let hmodule = unsafe { GetModuleHandleW(std::ptr::null()) };
384 std::num::NonZeroIsize::new(hmodule)
385}
386
387/// Bridge a baseview raw-window-handle 0.5 to a wgpu-compatible
388/// `SurfaceTargetUnsafe` using rwh 0.6 types.
389///
390/// # Safety
391/// The window handle must be valid for the lifetime of the returned surface.
392#[cfg(not(target_os = "ios"))]
393#[must_use]
394pub unsafe fn create_wgpu_surface(
395 instance: &wgpu::Instance,
396 window: &baseview::Window,
397) -> Option<wgpu::Surface<'static>> {
398 unsafe {
399 let rwh = window.raw_window_handle();
400 let surface_target = match rwh {
401 #[cfg(target_os = "macos")]
402 RwhRawWindowHandle::AppKit(handle) => {
403 let ns_view = handle.ns_view;
404 if ns_view.is_null() {
405 return None;
406 }
407 let rwh6_window = wgpu::rwh::RawWindowHandle::AppKit(
408 wgpu::rwh::AppKitWindowHandle::new(std::ptr::NonNull::new(ns_view)?),
409 );
410 let rwh6_display =
411 wgpu::rwh::RawDisplayHandle::AppKit(wgpu::rwh::AppKitDisplayHandle::new());
412 wgpu::SurfaceTargetUnsafe::RawHandle {
413 raw_display_handle: Some(rwh6_display),
414 raw_window_handle: rwh6_window,
415 }
416 }
417 #[cfg(target_os = "windows")]
418 RwhRawWindowHandle::Win32(handle) => {
419 let hwnd = handle.hwnd;
420 if hwnd.is_null() {
421 return None;
422 }
423 let mut win32 =
424 wgpu::rwh::Win32WindowHandle::new(std::num::NonZeroIsize::new(hwnd as isize)?);
425 // wgpu's Vulkan backend requires `hinstance` to be set
426 // (`vkCreateWin32SurfaceKHR` rejects a null HINSTANCE).
427 // baseview leaves the rwh 0.5 `hinstance` field at null,
428 // so populate it here with the running module's HMODULE.
429 // DX12 didn't require this, which is why the egui 0.34
430 // migration's switch from DX12 to Vulkan exposed it.
431 win32.hinstance = current_module_hinstance();
432 let rwh6_window = wgpu::rwh::RawWindowHandle::Win32(win32);
433 let rwh6_display =
434 wgpu::rwh::RawDisplayHandle::Windows(wgpu::rwh::WindowsDisplayHandle::new());
435 wgpu::SurfaceTargetUnsafe::RawHandle {
436 raw_display_handle: Some(rwh6_display),
437 raw_window_handle: rwh6_window,
438 }
439 }
440 #[cfg(target_os = "linux")]
441 RwhRawWindowHandle::Xlib(handle) => {
442 let RwhRawDisplayHandle::Xlib(display_handle) = window.raw_display_handle() else {
443 return None;
444 };
445 let display_ptr = std::ptr::NonNull::new(display_handle.display);
446 let rwh6_window = wgpu::rwh::RawWindowHandle::Xlib(
447 wgpu::rwh::XlibWindowHandle::new(handle.window),
448 );
449 let rwh6_display = wgpu::rwh::RawDisplayHandle::Xlib(
450 wgpu::rwh::XlibDisplayHandle::new(display_ptr, display_handle.screen),
451 );
452 wgpu::SurfaceTargetUnsafe::RawHandle {
453 raw_display_handle: Some(rwh6_display),
454 raw_window_handle: rwh6_window,
455 }
456 }
457 _ => return None,
458 };
459
460 instance.create_surface_unsafe(surface_target).ok()
461 }
462}