Skip to main content

vst3_host/
window.rs

1//! Plugin window management
2//!
3//! This module provides platform-specific window creation and management
4//! for VST3 plugin GUIs.
5
6use crate::error::{Error, Result};
7use crate::plugin::Plugin;
8use std::sync::{Arc, Mutex};
9
10#[cfg(target_os = "macos")]
11use objc2::{rc::Retained, MainThreadMarker, MainThreadOnly};
12#[cfg(target_os = "macos")]
13use objc2_app_kit::{NSApplication, NSBackingStoreType, NSView, NSWindow, NSWindowStyleMask};
14#[cfg(target_os = "macos")]
15use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
16
17#[cfg(target_os = "windows")]
18use winapi::{
19    shared::minwindef::{LPARAM, LRESULT, UINT, WPARAM},
20    shared::windef::{HWND, RECT},
21    um::libloaderapi::GetModuleHandleW,
22    um::winuser::{
23        CreateWindowExW, DefWindowProcW, DestroyWindow, LoadCursorW, RegisterClassExW,
24        SetWindowPos, ShowWindow, UpdateWindow, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, IDC_ARROW,
25        SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOZORDER, SW_SHOW, WM_CLOSE, WM_DPICHANGED, WNDCLASSEXW,
26        WS_OVERLAPPEDWINDOW,
27    },
28};
29
30#[cfg(any(test, target_os = "windows"))]
31fn dpi_scale_factor(dpi: u32) -> Option<f32> {
32    (dpi > 0).then_some(dpi as f32 / 96.0)
33}
34
35/// Editor windows whose window procedure has seen `WM_CLOSE`, keyed by `HWND` (as `usize`,
36/// since `HWND` is a raw pointer and not `Send`).
37///
38/// `DefWindowProcW` answers `WM_CLOSE` by destroying the window, which would leave the plugin's
39/// `IPlugView` attached to a freed `HWND`. [`plugin_window_proc`] records the request here
40/// instead, so the host can tear the editor down in the right order.
41#[cfg(target_os = "windows")]
42fn close_requests() -> &'static Mutex<std::collections::HashSet<usize>> {
43    static REQUESTS: std::sync::OnceLock<Mutex<std::collections::HashSet<usize>>> =
44        std::sync::OnceLock::new();
45    REQUESTS.get_or_init(Mutex::default)
46}
47
48/// Latest pending DPI for each editor window. The window procedure cannot call into the plugin:
49/// `setContentScaleFactor` may synchronously enter arbitrary plugin/UI code, while callers often
50/// hold the plugin mutex when Windows dispatches a message. The procedure records only the
51/// newest DPI and [`PluginWindow::service_platform_events`] applies it after the callback returns.
52#[cfg(target_os = "windows")]
53fn dpi_changes() -> &'static Mutex<std::collections::HashMap<usize, u32>> {
54    static CHANGES: std::sync::OnceLock<Mutex<std::collections::HashMap<usize, u32>>> =
55        std::sync::OnceLock::new();
56    CHANGES.get_or_init(Mutex::default)
57}
58
59#[cfg(target_os = "windows")]
60fn dpi_from_wparam(wparam: WPARAM) -> Option<u32> {
61    // WM_DPICHANGED packs the new X DPI into LOWORD and Y DPI into HIWORD. VST3 has one content
62    // scale, and Windows normally reports both equally, so use X as Microsoft recommends.
63    let dpi = (wparam & 0xffff) as u32;
64    (dpi > 0).then_some(dpi)
65}
66
67/// Window procedure for plugin editor windows: record control-plane work and defer plugin calls
68/// until after Windows returns from the callback.
69///
70/// # Safety
71///
72/// Called by the OS with the arguments of a window procedure; all it does with them is forward
73/// them to `DefWindowProcW`.
74#[cfg(target_os = "windows")]
75unsafe extern "system" fn plugin_window_proc(
76    hwnd: HWND,
77    msg: UINT,
78    wparam: WPARAM,
79    lparam: LPARAM,
80) -> LRESULT {
81    if msg == WM_CLOSE {
82        if let Ok(mut requests) = close_requests().lock() {
83            requests.insert(hwnd as usize);
84        }
85        return 0;
86    }
87    if msg == WM_DPICHANGED {
88        // The suggested rectangle is valid only for this callback and must be applied
89        // synchronously. Do that before taking any host mutex: SetWindowPos may dispatch more
90        // window messages reentrantly.
91        if let Some(suggested) = (lparam as *const RECT).as_ref() {
92            SetWindowPos(
93                hwnd,
94                std::ptr::null_mut(),
95                suggested.left,
96                suggested.top,
97                suggested.right - suggested.left,
98                suggested.bottom - suggested.top,
99                SWP_NOZORDER | SWP_NOACTIVATE,
100            );
101        }
102        if let Some(dpi) = dpi_from_wparam(wparam) {
103            if let Ok(mut changes) = dpi_changes().lock() {
104                changes.insert(hwnd as usize, dpi);
105            }
106        }
107        return 0;
108    }
109    DefWindowProcW(hwnd, msg, wparam, lparam)
110}
111
112/// An X11 window (connection + window id) backing a plugin editor on Linux.
113///
114/// Ported from the khremeviuc1004 fork's XCB implementation.
115#[cfg(target_os = "linux")]
116struct XcbWindowState {
117    connection: xcb::Connection,
118    window: xcb::x::Window,
119}
120
121/// A plugin window that manages the native window and plugin editor lifecycle
122pub struct PluginWindow {
123    plugin: Arc<Mutex<Plugin>>,
124    #[cfg(target_os = "macos")]
125    native_window: Option<Retained<NSWindow>>,
126    /// The view the plugin attached its editor into. Kept so a plugin-initiated resize can
127    /// grow the container along with the window.
128    #[cfg(target_os = "macos")]
129    container_view: Option<Retained<NSView>>,
130    #[cfg(target_os = "windows")]
131    native_window: Option<HWND>,
132    #[cfg(target_os = "linux")]
133    native_window: Option<XcbWindowState>,
134    #[cfg(target_os = "android")]
135    native_window: Option<()>,
136}
137
138impl PluginWindow {
139    /// Create a new plugin window for the given plugin
140    pub fn new(plugin: Arc<Mutex<Plugin>>) -> Self {
141        Self {
142            plugin,
143            #[cfg(any(
144                target_os = "macos",
145                target_os = "windows",
146                target_os = "linux",
147                target_os = "android"
148            ))]
149            native_window: None,
150            #[cfg(target_os = "macos")]
151            container_view: None,
152        }
153    }
154
155    /// Open the plugin window
156    pub fn open(&mut self) -> Result<()> {
157        // Check if plugin has editor
158        let has_editor = self
159            .plugin
160            .lock()
161            .unwrap_or_else(|p| p.into_inner())
162            .has_editor();
163        if !has_editor {
164            return Err(Error::Other(
165                "Plugin does not have a GUI editor".to_string(),
166            ));
167        }
168
169        // Close existing window if any. Keyed on the handle rather than `is_open()`, which also
170        // reports `false` for a window the user already dismissed — that one still needs closing.
171        if self.native_window.is_some() {
172            self.close();
173        }
174
175        // Get plugin info for window title
176        let plugin_info = self
177            .plugin
178            .lock()
179            .unwrap_or_else(|p| p.into_inner())
180            .info()
181            .clone();
182
183        // Try to get editor size
184        let (width, height) = self
185            .plugin
186            .lock()
187            .unwrap_or_else(|p| p.into_inner())
188            .get_editor_size()
189            .unwrap_or((800, 600));
190
191        // Create native window
192        #[cfg(target_os = "macos")]
193        {
194            // AppKit objects must be created on the main thread.
195            let mtm = MainThreadMarker::new().ok_or_else(|| {
196                Error::Other("plugin editor window must be opened on the main thread".to_string())
197            })?;
198
199            let frame = NSRect::new(
200                NSPoint::new(100.0, 100.0),
201                NSSize::new(width as f64, height as f64),
202            );
203            let style = NSWindowStyleMask::Titled
204                | NSWindowStyleMask::Closable
205                | NSWindowStyleMask::Miniaturizable;
206
207            // SAFETY: standard AppKit window/view construction on the main thread.
208            let window = unsafe {
209                NSWindow::initWithContentRect_styleMask_backing_defer(
210                    NSWindow::alloc(mtm),
211                    frame,
212                    style,
213                    NSBackingStoreType::Buffered,
214                    false,
215                )
216            };
217
218            // Programmatic NSWindows default to `releasedWhenClosed = YES`; closing one would
219            // then release it while our `Retained<NSWindow>` also releases on drop — a
220            // double-free that crashes on close. We own the lifetime, so opt out.
221            // SAFETY: standard AppKit setter on the main thread.
222            unsafe { window.setReleasedWhenClosed(false) };
223
224            let title = NSString::from_str(&format!("{} - VST3", plugin_info.name));
225            window.setTitle(&title);
226
227            // A container view, sized to the editor, that the plugin attaches its view into.
228            let container_frame = NSRect::new(
229                NSPoint::new(0.0, 0.0),
230                NSSize::new(width as f64, height as f64),
231            );
232            let container_view = NSView::initWithFrame(NSView::alloc(mtm), container_frame);
233            if let Some(content_view) = window.contentView() {
234                content_view.addSubview(&container_view);
235            }
236
237            // Hand the plugin the container NSView to embed its editor in.
238            // SAFETY: `container_view` is a live NSView this `PluginWindow` keeps alive (via
239            // the retained window it was added to) for as long as the editor is attached —
240            // `close()` detaches the editor before dropping the window.
241            let window_handle = unsafe {
242                crate::plugin::WindowHandle::from_nsview(
243                    Retained::as_ptr(&container_view) as *mut std::ffi::c_void
244                )
245            };
246            self.plugin
247                .lock()
248                .unwrap_or_else(|p| p.into_inner())
249                .open_editor(window_handle)?;
250
251            // Match the window to the editor size, then show and center it.
252            window.setContentSize(container_frame.size);
253            window.makeKeyAndOrderFront(None);
254            window.center();
255
256            self.native_window = Some(window);
257            self.container_view = Some(container_view);
258        }
259
260        #[cfg(target_os = "windows")]
261        {
262            unsafe {
263                use std::mem;
264                use std::ptr;
265
266                // Register window class if not already registered
267                let class_name = "VST3PluginWindow\0".encode_utf16().collect::<Vec<u16>>();
268                let mut wc: WNDCLASSEXW = mem::zeroed();
269                wc.cbSize = mem::size_of::<WNDCLASSEXW>() as UINT;
270                wc.style = CS_HREDRAW | CS_VREDRAW;
271                wc.lpfnWndProc = Some(plugin_window_proc);
272                wc.hInstance = GetModuleHandleW(ptr::null());
273                wc.hCursor = LoadCursorW(ptr::null_mut(), IDC_ARROW);
274                wc.lpszClassName = class_name.as_ptr();
275
276                // Try to register, ignore if already registered
277                RegisterClassExW(&wc);
278
279                // Create window
280                let window_title = format!("{} - VST3\0", plugin_info.name);
281                let window_name = window_title.encode_utf16().collect::<Vec<u16>>();
282
283                // Calculate window size including borders
284                let mut rect = RECT {
285                    left: 0,
286                    top: 0,
287                    right: width,
288                    bottom: height,
289                };
290
291                winapi::um::winuser::AdjustWindowRectEx(
292                    &mut rect,
293                    WS_OVERLAPPEDWINDOW,
294                    0, // No menu
295                    0, // No extended style
296                );
297
298                let window_width = rect.right - rect.left;
299                let window_height = rect.bottom - rect.top;
300
301                let hwnd = CreateWindowExW(
302                    0,
303                    class_name.as_ptr(),
304                    window_name.as_ptr(),
305                    WS_OVERLAPPEDWINDOW,
306                    CW_USEDEFAULT,
307                    CW_USEDEFAULT,
308                    window_width,
309                    window_height,
310                    ptr::null_mut(),
311                    ptr::null_mut(),
312                    GetModuleHandleW(ptr::null()),
313                    ptr::null_mut(),
314                );
315
316                if hwnd.is_null() {
317                    return Err(Error::Other("Failed to create native window".to_string()));
318                }
319
320                // Try to open plugin editor.
321                // SAFETY: `hwnd` was just created above and null-checked; it is destroyed only
322                // after the editor is detached (in `close()`, or the error arm below).
323                let window_handle =
324                    crate::plugin::WindowHandle::from_hwnd(hwnd as *mut std::ffi::c_void);
325                let mut plugin = self.plugin.lock().unwrap_or_else(|p| p.into_inner());
326                let dpi = winapi::um::winuser::GetDpiForWindow(hwnd);
327                if let Some(scale_factor) = dpi_scale_factor(dpi) {
328                    if let Err(error) = plugin.set_editor_scale_factor(scale_factor) {
329                        drop(plugin);
330                        DestroyWindow(hwnd);
331                        return Err(error);
332                    }
333                }
334                match plugin.open_editor(window_handle) {
335                    Ok(()) => {
336                        drop(plugin);
337                        ShowWindow(hwnd, SW_SHOW);
338                        UpdateWindow(hwnd);
339                        self.native_window = Some(hwnd);
340                    }
341                    Err(e) => {
342                        drop(plugin);
343                        DestroyWindow(hwnd);
344                        return Err(e);
345                    }
346                }
347            }
348        }
349
350        #[cfg(target_os = "linux")]
351        {
352            use xcb::Xid;
353
354            // Create an X11 window via XCB and embed the plugin editor into it using the
355            // VST3 X11EmbedWindowID platform type (handled in plugin_impl::open_editor).
356            let (connection, screen_number) = xcb::Connection::connect(None)
357                .map_err(|e| Error::Other(format!("Failed to connect to X server: {e}")))?;
358            let setup = connection.get_setup();
359            let screen = setup
360                .roots()
361                .nth(screen_number as usize)
362                .ok_or_else(|| Error::Other("No X11 screen found".to_string()))?;
363            let window = connection.generate_id();
364
365            connection
366                .send_and_check_request(&xcb::x::CreateWindow {
367                    depth: xcb::x::COPY_FROM_PARENT as u8,
368                    wid: window,
369                    parent: screen.root(),
370                    x: 0,
371                    y: 0,
372                    width: width as u16,
373                    height: height as u16,
374                    border_width: 0,
375                    class: xcb::x::WindowClass::InputOutput,
376                    visual: screen.root_visual(),
377                    value_list: &[
378                        xcb::x::Cw::BackPixel(screen.white_pixel()),
379                        xcb::x::Cw::EventMask(
380                            xcb::x::EventMask::EXPOSURE | xcb::x::EventMask::KEY_PRESS,
381                        ),
382                    ],
383                })
384                .map_err(|e| Error::Other(format!("Failed to create X11 window: {e}")))?;
385
386            // Window title.
387            let title = format!("{} - VST3", plugin_info.name);
388            connection.send_request(&xcb::x::ChangeProperty {
389                mode: xcb::x::PropMode::Replace,
390                window,
391                property: xcb::x::ATOM_WM_NAME,
392                r#type: xcb::x::ATOM_STRING,
393                data: title.as_bytes(),
394            });
395
396            // Show the window, then attach the plugin editor to its X11 id.
397            connection.send_request(&xcb::x::MapWindow { window });
398            let _ = connection.flush();
399
400            let handle = crate::plugin::WindowHandle::from_x11(window.resource_id());
401            self.plugin
402                .lock()
403                .unwrap_or_else(|p| p.into_inner())
404                .open_editor(handle)?;
405
406            self.native_window = Some(XcbWindowState { connection, window });
407        }
408
409        #[cfg(target_os = "android")]
410        {
411            return Err(Error::Other(
412                "PluginWindow::open() is not supported on Android".to_string(),
413            ));
414        }
415
416        Ok(())
417    }
418
419    /// Pump the editor's window-level traffic that has to cross into the plugin.
420    ///
421    /// **Call this from your UI event loop, once per frame, while the window is open.** Two
422    /// things depend on it:
423    ///
424    /// - **Plugin-initiated resizes.** A resizable editor (VSTGUI zoom, a "big view" toggle)
425    ///   asks the host to resize through `IPlugFrame::resizeView`. This applies the request to
426    ///   the native window, so the editor is not clipped by a window that never grew.
427    /// - **Windows DPI changes.** `WM_DPICHANGED` applies its suggested native rectangle
428    ///   immediately in the window procedure and queues the new content scale for here; making
429    ///   the COM call outside the window procedure prevents reentrant deadlocks on the plugin
430    ///   mutex.
431    ///
432    /// Deliberately non-blocking: if the plugin mutex is held elsewhere (the audio callback
433    /// holds it per block) this returns without doing anything, and the pending work is picked
434    /// up on the next call.
435    ///
436    /// [`Self::is_open`] and [`Self::closed_by_user`] do *not* run this — they stay lock-free
437    /// so they are safe to call while holding the plugin lock.
438    pub fn service_platform_events(&self) -> Result<()> {
439        if self.native_window.is_none() {
440            return Ok(());
441        }
442        let Some(mut plugin) = self.try_lock_plugin() else {
443            return Ok(());
444        };
445
446        let scale_result = self
447            .take_pending_scale_factor()
448            .map(|factor| plugin.set_editor_scale_factor(factor));
449        let resize = plugin.take_editor_resize_request();
450
451        // Released before touching the native window: on Windows, resizing it may synchronously
452        // dispatch window messages back into host code that wants this same lock.
453        drop(plugin);
454
455        if let Some((width, height)) = resize {
456            self.resize_native_window(width, height);
457        }
458        match scale_result {
459            Some(Err(error)) => Err(error),
460            _ => Ok(()),
461        }
462    }
463
464    /// Take the plugin lock without blocking, recovering a lock poisoned by an unrelated panic
465    /// (a poisoned mutex is permanent, and treating it as failure would stop servicing the
466    /// editor for the rest of the session).
467    fn try_lock_plugin(&self) -> Option<std::sync::MutexGuard<'_, Plugin>> {
468        match self.plugin.try_lock() {
469            Ok(guard) => Some(guard),
470            Err(std::sync::TryLockError::Poisoned(poison)) => Some(poison.into_inner()),
471            Err(std::sync::TryLockError::WouldBlock) => None,
472        }
473    }
474
475    /// The newest DPI the window procedure recorded for this window, as a VST3 content scale.
476    #[cfg(target_os = "windows")]
477    fn take_pending_scale_factor(&self) -> Option<f32> {
478        let hwnd = self.native_window?;
479        dpi_changes()
480            .lock()
481            .unwrap_or_else(|poison| poison.into_inner())
482            .remove(&(hwnd as usize))
483            .and_then(dpi_scale_factor)
484    }
485
486    /// Only Windows reports DPI changes through the window procedure.
487    #[cfg(not(target_os = "windows"))]
488    fn take_pending_scale_factor(&self) -> Option<f32> {
489        None
490    }
491
492    /// Resize the native window so it fits an editor of `width` x `height` pixels.
493    ///
494    /// The plugin has already resized its own view (the host answered `resizeView` with
495    /// `onSize`); this catches the window up.
496    fn resize_native_window(&self, width: i32, height: i32) {
497        if width <= 0 || height <= 0 {
498            return;
499        }
500        log::debug!("editor asked the host to resize its window to {width}x{height}");
501
502        #[cfg(target_os = "macos")]
503        {
504            let Some(window) = self.native_window.as_ref() else {
505                return;
506            };
507            let size = NSSize::new(width as f64, height as f64);
508            if let Some(container) = self.container_view.as_ref() {
509                container.setFrame(NSRect::new(NSPoint::new(0.0, 0.0), size));
510            }
511            window.setContentSize(size);
512        }
513
514        #[cfg(target_os = "windows")]
515        {
516            let Some(hwnd) = self.native_window else {
517                return;
518            };
519            // The plugin sizes its *client* area; grow the frame by the window's chrome, the
520            // same way `open()` sizes the window to the editor in the first place.
521            let mut rect = RECT {
522                left: 0,
523                top: 0,
524                right: width,
525                bottom: height,
526            };
527            unsafe {
528                winapi::um::winuser::AdjustWindowRectEx(&mut rect, WS_OVERLAPPEDWINDOW, 0, 0);
529                SetWindowPos(
530                    hwnd,
531                    std::ptr::null_mut(),
532                    0,
533                    0,
534                    rect.right - rect.left,
535                    rect.bottom - rect.top,
536                    SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE,
537                );
538            }
539        }
540
541        #[cfg(target_os = "linux")]
542        {
543            let Some(state) = self.native_window.as_ref() else {
544                return;
545            };
546            state.connection.send_request(&xcb::x::ConfigureWindow {
547                window: state.window,
548                value_list: &[
549                    xcb::x::ConfigWindow::Width(width as u32),
550                    xcb::x::ConfigWindow::Height(height as u32),
551                ],
552            });
553            let _ = state.connection.flush();
554        }
555
556        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
557        let _ = (width, height);
558    }
559
560    /// Close the plugin window
561    pub fn close(&mut self) {
562        // Detach the plugin editor first — it must never outlive the window it is embedded in.
563        // A poisoned lock (an earlier panic on the audio thread) is recovered rather than
564        // skipped, exactly as every other lock site here does: skipping it would destroy the
565        // native window with the plugin's view still attached to it.
566        let _ = self
567            .plugin
568            .lock()
569            .unwrap_or_else(|p| p.into_inner())
570            .close_editor();
571
572        // Then close the native window
573        #[cfg(target_os = "macos")]
574        {
575            self.container_view = None;
576            if let Some(window) = self.native_window.take() {
577                window.close();
578            }
579        }
580
581        #[cfg(target_os = "windows")]
582        {
583            if let Some(hwnd) = self.native_window.take() {
584                if let Ok(mut requests) = close_requests().lock() {
585                    requests.remove(&(hwnd as usize));
586                }
587                if let Ok(mut changes) = dpi_changes().lock() {
588                    changes.remove(&(hwnd as usize));
589                }
590                unsafe {
591                    DestroyWindow(hwnd);
592                }
593            }
594        }
595
596        #[cfg(target_os = "linux")]
597        {
598            if let Some(state) = self.native_window.take() {
599                state.connection.send_request(&xcb::x::UnmapWindow {
600                    window: state.window,
601                });
602                state.connection.send_request(&xcb::x::DestroyWindow {
603                    window: state.window,
604                });
605                let _ = state.connection.flush();
606            }
607        }
608
609        #[cfg(target_os = "android")]
610        {
611            let _ = self.native_window.take();
612        }
613    }
614
615    /// Check if the window is currently open.
616    ///
617    /// Reports `false` once the user has dismissed the window themselves, even though the
618    /// handle is still around — see [`Self::closed_by_user`].
619    ///
620    /// Reads native window state only: it never takes the plugin lock, so it is safe to call
621    /// while holding it. Pair it with [`Self::service_platform_events`], which does the work
622    /// that has to reach the plugin.
623    pub fn is_open(&self) -> bool {
624        self.native_window.is_some() && !self.native_window_dismissed()
625    }
626
627    /// Whether the user closed the window themselves, through its title-bar close button.
628    ///
629    /// Neither the plugin nor the host is told when that happens, so a host that tracks "the
630    /// editor is open" in its own UI should poll this each frame and drop or
631    /// [`close`](Self::close) the window when it reports `true`. Otherwise the editor stays
632    /// attached to a window that is gone from the screen.
633    ///
634    /// Cheap and lock-free in the same sense as [`Self::is_open`]: native window state only,
635    /// never the plugin lock.
636    pub fn closed_by_user(&self) -> bool {
637        self.native_window_dismissed()
638    }
639
640    /// Platform probe behind [`Self::closed_by_user`].
641    #[cfg(target_os = "macos")]
642    fn native_window_dismissed(&self) -> bool {
643        let Some(window) = self.native_window.as_ref() else {
644            return false;
645        };
646        // A closed window is ordered out — but so is a miniaturized one, and so is every window
647        // of a hidden application. Neither of those means the user dismissed the editor.
648        if window.isVisible() || window.isMiniaturized() {
649            return false;
650        }
651        match MainThreadMarker::new() {
652            Some(mtm) => !NSApplication::sharedApplication(mtm).isHidden(),
653            // AppKit state can only be read from the main thread; assume the window still stands.
654            None => false,
655        }
656    }
657
658    /// Platform probe behind [`Self::closed_by_user`].
659    #[cfg(target_os = "windows")]
660    fn native_window_dismissed(&self) -> bool {
661        let Some(hwnd) = self.native_window else {
662            return false;
663        };
664        close_requests()
665            .lock()
666            .is_ok_and(|requests| requests.contains(&(hwnd as usize)))
667    }
668
669    /// Platform probe behind [`Self::closed_by_user`]. X11 and Android editor windows carry no
670    /// close affordance of their own, so there is nothing to observe.
671    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
672    fn native_window_dismissed(&self) -> bool {
673        false
674    }
675}
676
677impl Drop for PluginWindow {
678    fn drop(&mut self) {
679        self.close();
680    }
681}
682
683/// Builder for creating plugin windows with egui integration
684#[cfg(feature = "egui-widgets")]
685pub struct PluginWindowBuilder {
686    plugin: Arc<Mutex<Plugin>>,
687}
688
689#[cfg(feature = "egui-widgets")]
690impl PluginWindowBuilder {
691    /// Create a new builder for the given plugin
692    pub fn new(plugin: Arc<Mutex<Plugin>>) -> Self {
693        Self { plugin }
694    }
695
696    /// Build and open a standalone plugin window
697    pub fn open_standalone(&self) -> Result<PluginWindow> {
698        let mut window = PluginWindow::new(self.plugin.clone());
699        window.open()?;
700        Ok(window)
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::dpi_scale_factor;
707
708    #[test]
709    fn windows_dpi_converts_to_vst_content_scale() {
710        assert_eq!(dpi_scale_factor(0), None);
711        assert_eq!(dpi_scale_factor(96), Some(1.0));
712        assert_eq!(dpi_scale_factor(120), Some(1.25));
713        assert_eq!(dpi_scale_factor(144), Some(1.5));
714        assert_eq!(dpi_scale_factor(192), Some(2.0));
715    }
716
717    #[cfg(target_os = "windows")]
718    #[test]
719    fn wm_dpi_wparam_uses_horizontal_dpi() {
720        let packed = (192usize << 16) | 144;
721        assert_eq!(super::dpi_from_wparam(packed), Some(144));
722        assert_eq!(super::dpi_from_wparam(0), None);
723    }
724}