collective

Browse / github.com/storytold/soundcraft / patch

VST3/CLAP hosts: resizable plugin editor windows negotiated with the plugin

VST3/CLAP hosts: resizable plugin editor windows negotiated with the plugin Plugin editors whose view can resize now get a resizable host window, and user resizes follow the plugin's constraints. - **VST3 (macOS, Windows):** after `attached()`, if `IPlugView::canResize()`, the window gets a resize handle. Drags ask `checkSizeConstraint`; finished resizes snap to it and call `onSize`. The macOS zoom button respects the constraints. - **CLAP embedded editors (Windows):** the same flow with `can_resize` / `adjust_size` / `set_size`; a size the plugin refuses reverts the window. - **No feedback loops:** host-applied sizes don't re-trigger window notifications, and `resiz

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1038, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+1205 −47 in 10 files
Review
pending
Shared
2026-10-28
Id
b9208155c7660a69e6d43225

How it went

No other operator has reported using it yet.

Diff

raw · crates/clap-host/src/ffi.rs crates/clap-host/src/lib.rs crates/vst3-host/Cargo.toml crates/vst3-host/src/ffi.rs crates/vst3-host/src/lib.rs crates/vst3-host/src/plugin.rs crates/vst3-host/tests/editor_main_thread.rs crates/vst3-host/tests/fixture/src/lib.rs crates/vst3-host/tests/it/fixture_e2e.rs docs/plugin-parity.md

diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs--- a/crates/clap-host/src/ffi.rs+++ b/crates/clap-host/src/ffi.rs@@ -40,7 +40,7 @@ use soundcraft_dsp::Transport; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf};-use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};+use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError};  /// Most plugins one factory may report (hostile-input cap).@@ -279,7 +279,16 @@ impl Bundle {         inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);         let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);         inst.gui = Arc::new(GuiLink {-            inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),+            inner: Mutex::new(GuiInner {+                plugin,+                gui,+                state: inst.state_ext,+                alive: true,+                created: false,+                visible: false,+                window: None,+                sizer: None,+            }),             host: Arc::clone(&inst.state),         });         Ok(inst)@@ -742,6 +751,197 @@ fn window_api() -> Option<&'static CStr> {     } } +/// Negotiates user resizes of an embedded editor's host window with the plugin (CLAP: when+/// `can_resize`, the host asks `adjust_size`, sizes the window to the answer, then `set_size`).+///+/// Shared by the editor link and the host window's procedure. Everything runs on the main thread;+/// the lock only guards against the instance clearing the plugin from another thread while a+/// resize calls into it. Loops are cut by `applying`: while the host applies a size (a plugin's+/// `request_resize`, or a user resize being constrained and handed to `set_size`), the window+/// notifications that this causes are ignored rather than negotiated again.+pub(crate) struct GuiSizer {+    target: Mutex<Option<SizeTarget>>,+    /// The thread inside a call into the plugin under `target`'s lock (see `with_target`).+    holder: Mutex<Option<std::thread::ThreadId>>,+    /// A target change requested re-entrantly by the holder thread, applied when its call returns.+    pending: Mutex<Option<Option<SizeTarget>>>,+    /// The plugin said `can_resize`: user resizes are negotiated (the window has a sizing border).+    resizable: AtomicBool,+    /// Nesting depth of resizes being applied by the host.+    applying: AtomicU32,+    /// The size last given to the plugin (`width << 32 | height`), to skip repeated `set_size`.+    last: AtomicU64,+}++// User resizes come from the Windows host window only; elsewhere the plugin side is unused.+#[cfg_attr(not(windows), allow(dead_code))]+/// The plugin and the two `clap.gui` calls a user resize needs.+#[derive(Clone, Copy)]+struct SizeTarget {+    plugin: *const clap_plugin,+    adjust_size: Option<unsafe extern "C" fn(*const clap_plugin, *mut u32, *mut u32) -> bool>,+    set_size: Option<unsafe extern "C" fn(*const clap_plugin, u32, u32) -> bool>,+}++// SAFETY: the plugin pointer is only called through on the main thread (the window procedure),+// under the sizer's lock, and the editor link clears it under that lock (or, re-entrantly, right+// after the call holding it) before the GUI goes.+unsafe impl Send for SizeTarget {}++/// Ends an "applying" section of a [`GuiSizer`] when dropped.+pub(crate) struct Applying<'a>(&'a AtomicU32);++impl Drop for Applying<'_> {+    fn drop(&mut self) {+        // Paired with the increment in `GuiSizer::applying`: never below zero.+        self.0.fetch_sub(1, Ordering::Relaxed);+    }+}++// User resizes come from the Windows host window only; elsewhere the plugin side is unused.+#[cfg_attr(not(windows), allow(dead_code))]+/// A host-window size in whole pixels (non-finite or out-of-range sizes are clamped).+fn px(v: f64) -> u32 {+    if v.is_finite() { v.round().clamp(1.0, 16_384.0) as u32 } else { 1 }+}++// User resizes come from the Windows host window only; elsewhere the plugin side is unused.+#[cfg_attr(not(windows), allow(dead_code))]+impl GuiSizer {+    pub fn new(w: u32, h: u32) -> GuiSizer {+        let s = GuiSizer {+            target: Mutex::new(None),+            holder: Mutex::new(None),+            pending: Mutex::new(None),+            resizable: AtomicBool::new(false),+            applying: AtomicU32::new(0),+            last: AtomicU64::new(0),+        };+        s.set_last(w, h);+        s+    }++    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)+    }++    fn pending(&self) -> std::sync::MutexGuard<'_, Option<Option<SizeTarget>>> {+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Runs `f` on the plugin under the target lock (`None` without one). A target change+    /// requested from inside `f` on this thread (a plugin pumping a nested event loop that closes+    /// the editor) is deferred until `f` returns instead of deadlocking on the lock.+    fn with_target<R>(&self, f: impl FnOnce(&SizeTarget) -> R) -> Option<R> {+        let mut g = self.target.lock().unwrap_or_else(PoisonError::into_inner);+        *self.holder() = Some(std::thread::current().id());+        let out = g.as_ref().map(f);+        *self.holder() = None;+        if let Some(t) = self.pending().take() {+            *g = t;+        }+        out+    }++    /// Hands the plugin over once its GUI is parented and shown (`can_resize` decides whether+    /// user resizes are negotiated); `None` takes it away again (before the GUI is destroyed).+    fn set_target(&self, target: Option<SizeTarget>, resizable: bool) {+        self.resizable.store(resizable && target.is_some(), Ordering::Relaxed);+        if *self.holder() == Some(std::thread::current().id()) {+            *self.pending() = Some(target);+            return;+        }+        *self.target.lock().unwrap_or_else(PoisonError::into_inner) = target;+    }++    pub fn resizable(&self) -> bool {+        self.resizable.load(Ordering::Relaxed)+    }++    /// Marks a host-applied resize; window notifications are ignored until the guard drops.+    pub fn applying(&self) -> Applying<'_> {+        self.applying.fetch_add(1, Ordering::Relaxed);+        Applying(&self.applying)+    }++    fn is_applying(&self) -> bool {+        self.applying.load(Ordering::Relaxed) > 0+    }++    fn set_last(&self, w: u32, h: u32) {+        self.last.store((u64::from(w) << 32) | u64::from(h), Ordering::Relaxed);+    }++    fn last(&self) -> (u32, u32) {+        let v = self.last.load(Ordering::Relaxed);+        ((v >> 32) as u32, v as u32)+    }++    /// The size the plugin accepts instead of `w`×`h` (`adjust_size`), or `None` when no resize+    /// should be negotiated (it can't resize, is gone, or a resize is being applied).+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {+        if !self.resizable() || self.is_applying() {+            return None;+        }+        let _guard = self.applying();+        let (w, h) = self.with_target(|t| adjust(t, px(w), px(h)))?;+        Some((f64::from(w), f64::from(h)))+    }++    /// The window's client area was resized to `w`×`h` by the user (or the window manager):+    /// settles on the plugin's adjusted size, makes the window follow it through `set_window`+    /// when it differs, and tells the plugin with `set_size`. Ignored while the host applies a+    /// resize itself.+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {+        if !self.resizable() || self.is_applying() {+            return;+        }+        let _guard = self.applying();+        self.with_target(|t| {+            let (w, h) = (px(w), px(h));+            let (aw, ah) = adjust(t, w, h);+            let last = self.last();+            let accepted = last == (aw, ah)+                || match t.set_size {+                    // SAFETY: live plugin with a created, parented GUI (the editor link takes the+                    // target away before destroying it, deferred to here when that happens inside+                    // this call); main thread.+                    Some(f) => unsafe { f(t.plugin, aw, ah) },+                    None => false,+                };+            if accepted {+                self.set_last(aw, ah);+                if (aw, ah) != (w, h) {+                    set_window(f64::from(aw), f64::from(ah));+                }+            } else {+                // The plugin refused the size: the window goes back to the last one it took.+                set_window(f64::from(last.0), f64::from(last.1));+            }+        });+    }+}++// User resizes come from the Windows host window only; elsewhere the plugin side is unused.+#[cfg_attr(not(windows), allow(dead_code))]+/// `adjust_size` for a proposed `w`×`h`; the proposal stands when the plugin can't adjust it.+fn adjust(t: &SizeTarget, w: u32, h: u32) -> (u32, u32) {+    let Some(f) = t.adjust_size else { return (w, h) };+    let (mut aw, mut ah) = (w, h);+    // SAFETY: live plugin with a created GUI (see `user_resized`); both out-pointers are locals.+    if unsafe { f(t.plugin, &mut aw, &mut ah) } && aw > 0 && ah > 0 { (aw.min(16_384), ah.min(16_384)) } else { (w, h) }+}++/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.+#[cfg_attr(not(windows), allow(dead_code))]+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };+    (l, t, r, b)+}+ struct GuiInner {     plugin: *const clap_plugin,     gui: *const clap_plugin_gui,@@ -752,6 +952,8 @@ struct GuiInner {     visible: bool,     /// The host window of an embedded editor (`None` for floating editors).     window: Option<hostwin::HostWindow>,+    /// Negotiates user resizes of `window` (`None` for floating editors).+    sizer: Option<Arc<GuiSizer>>, }  /// The link between an instance and its editor and state handles. The instance may process on@@ -776,6 +978,7 @@ impl Default for GuiLink {                 created: false,                 visible: false,                 window: None,+                sizer: None,             }),             host: Arc::new(HostState::default()),         }@@ -911,14 +1114,30 @@ impl GuiLink {             GuiLink::destroy(g);             return Err("the plugin could not show its editor".into());         }-        if let Some(win) = &g.window {+        // The window follows `can_resize`: a sizing border, with user resizes negotiated through+        // `adjust_size` and `set_size`.+        // SAFETY: live plugin with a created, parented GUI; main thread.+        let resizable = vt.can_resize.is_some_and(|f| unsafe { f(g.plugin) });+        let sizer = Arc::new(GuiSizer::new(w, h));+        sizer.set_target(Some(SizeTarget { plugin: g.plugin, adjust_size: vt.adjust_size, set_size: vt.set_size }), resizable);+        if let Some(win) = &mut g.window {+            win.set_sizer(&sizer);+            // The frame change of the new sizing border is not a user resize: the window gives+            // the client area back to the plugin's size, with nothing negotiated meanwhile.+            let _applying = sizer.applying();+            win.set_resizable(resizable);             win.raise();         }+        g.sizer = Some(sizer);         g.visible = true;         Ok(())     }      fn destroy(g: &mut GuiInner) {+        // No more user resizes reach the plugin once its GUI is going.+        if let Some(s) = g.sizer.take() {+            s.set_target(None, false);+        }         if g.created             && let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)         {@@ -985,7 +1204,12 @@ impl GuiLink {                 && let Some(win) = &g.window             {                 let (w, h) = ((resize >> 32) as u32, resize as u32);+                // The window notifications this resize causes are not user resizes.+                let _applying = g.sizer.as_ref().map(|s| s.applying());                 hostwin::resize(win.raw(), f64::from(w), f64::from(h));+                if let Some(s) = &g.sizer {+                    s.set_last(w, h);+                }                 if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {                     // SAFETY: live plugin with a created embedded GUI, main thread.                     unsafe { f(g.plugin, w, h) };@@ -1030,8 +1254,12 @@ impl GuiLink {             } else {                 log::warn!("clap: editor still open while its plugin is destroyed off the main thread");                 g.created = false;-                // Window calls are main-thread only: leave the host window rather than crash.+                // Window calls are main-thread only: leave the host window rather than crash, but+                // cut its resize handling off from the plugin.                 g.window = None;+                if let Some(s) = g.sizer.take() {+                    s.set_target(None, false);+                }             }         }         g.alive = false;@@ -1443,15 +1671,18 @@ impl Drop for Instance { /// Messages are dispatched by the app's own event loop on the main thread. #[cfg(windows)] mod hostwin {+    use super::{GuiSizer, sizing_rect};     use std::ffi::c_void;-    use std::sync::OnceLock;+    use std::sync::{Arc, OnceLock};     use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};     use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;     use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;     use windows_sys::Win32::UI::WindowsAndMessaging::{         AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,-        GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,-        SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,     };      pub const SUPPORTED: bool = true;@@ -1467,11 +1698,83 @@ mod hostwin {      const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; +    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released+    /// on `WM_NCDESTROY`), borrowed for one message.+    fn window_sizer(hwnd: HWND) -> Option<Arc<GuiSizer>> {+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const GuiSizer;+        if p.is_null() {+            return None;+        }+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it+        // even when a plugin destroys the window from a nested event loop meanwhile.+        unsafe { Arc::increment_strong_count(p) };+        // SAFETY: balances the increment just made.+        Some(unsafe { Arc::from_raw(p) })+    }++    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {+            return None;+        }+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));+        Some((fw.max(0), fh.max(0)))+    }++    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {+        match frame_size(hwnd) {+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),+            None => {+                // SAFETY: a window of this thread; style values are only read.+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };+                outer_size(style, ex, w, h)+            }+        }+    }+     unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {-        if msg == WM_CLOSE {-            // SAFETY: `hwnd` is the window this procedure was called for.-            unsafe { ShowWindow(hwnd, SW_HIDE) };-            return 0;+        match msg {+            WM_CLOSE => {+                // SAFETY: `hwnd` is the window this procedure was called for.+                unsafe { ShowWindow(hwnd, SW_HIDE) };+                return 0;+            }+            // Live user resizes: keep the dragged edges at a size the plugin accepts.+            WM_SIZING => {+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));+                        return 1;+                    }+                }+            }+            // Every completed resize: settle on the adjusted size and tell the plugin.+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {+                if let Some(s) = window_sizer(hwnd) {+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));+                }+            }+            WM_NCDESTROY => {+                // SAFETY: the window's last message: take the user data back and release the+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const GuiSizer;+                if !p.is_null() {+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.+                    drop(unsafe { Arc::from_raw(p) });+                }+            }+            _ => {}         }         // SAFETY: default handling of a message for the window it was sent to.         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }@@ -1560,6 +1863,8 @@ mod hostwin {             if hwnd.is_null() {                 return Err("cannot create the editor window".into());             }+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.+            resize(hwnd, w, h);             Ok(HostWindow { hwnd })         } @@ -1568,6 +1873,42 @@ mod hostwin {             self.hwnd         } +        /// Routes the window's resizes through `sizer` (the window procedure reads it from the+        /// window's user data). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<GuiSizer>) {+            let Some(h) = self.live() else { return };+            if window_sizer(h).is_some() {+                return;+            }+            let p = Arc::into_raw(sizer.clone());+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`+            // releases it.+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };+        }++        /// Gives the window a sizing border (the plugin said `can_resize`), or takes it away,+        /// keeping the client size.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(h) = self.live() else { return };+            // SAFETY: a live window of this thread; the style is only read.+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {+                return;+            }+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };+            // SAFETY: a live window of this thread; `c` is writable.+            unsafe { GetClientRect(h, &mut c) };+            // SAFETY: as above; only the sizing-border bit of the style changes.+            unsafe {+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };+                SetWindowLongPtrW(h, GWL_STYLE, style as _);+            }+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };+            // The new frame took room from the client area: give it back.+            resize(h, f64::from(c.right), f64::from(c.bottom));+        }+         /// The `HWND` the plugin creates its child window in.         pub fn parent(&self) -> *mut c_void {             self.live().unwrap_or(std::ptr::null_mut())@@ -1602,10 +1943,8 @@ mod hostwin {         if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {             return;         }-        // SAFETY: a live window of this thread; style values are read, not changed.-        let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };-        let (ow, oh) = outer_size(style, ex, w, h);-        // SAFETY: as above; only the size changes.+        let (ow, oh) = outer_for(window, w, h);+        // SAFETY: a live window of this thread; only the size changes.         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };     } }@@ -1626,6 +1965,8 @@ mod hostwin {         pub fn raw(&self) -> *mut c_void {             std::ptr::null_mut()         }+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::GuiSizer>) {}+        pub fn set_resizable(&self, _resizable: bool) {}         pub fn parent(&self) -> *mut c_void {             std::ptr::null_mut()         }@@ -1643,6 +1984,113 @@ mod hostwin { mod tests {     use super::*; +    /// `set_size` calls of the fake plugin below (one test uses it).+    static SET_SIZES: Mutex<Vec<(u32, u32)>> = Mutex::new(Vec::new());+    /// When set, the fake `set_size` calls back into the sizer, the way a plugin resizing its+    /// parent from `set_size` makes the window notify the host again.+    static NESTED: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);+    /// When set, the fake `set_size` closes the editor from inside the call (takes the target+    /// away), the way a plugin pumping a nested event loop can.+    static CLOSE_INSIDE: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);+    /// The fake `set_size` refuses every size.+    static REFUSE: AtomicBool = AtomicBool::new(false);++    /// Accepts 200..=800 × 100..=600.+    unsafe extern "C" fn fake_adjust(_p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+        // SAFETY: the sizer passes valid out-pointers.+        unsafe {+            *w = (*w).clamp(200, 800);+            *h = (*h).clamp(100, 600);+        }+        true+    }++    unsafe extern "C" fn fake_set_size(_p: *const clap_plugin, w: u32, h: u32) -> bool {+        SET_SIZES.lock().unwrap().push((w, h));+        if let Some(s) = NESTED.lock().unwrap().clone() {+            s.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));+        }+        if let Some(s) = CLOSE_INSIDE.lock().unwrap().take() {+            s.set_target(None, false);+        }+        !REFUSE.load(Ordering::Relaxed)+    }++    #[test]+    fn user_resizes_are_negotiated_with_the_plugin_without_loops() {+        let target = SizeTarget { plugin: std::ptr::null(), adjust_size: Some(fake_adjust), set_size: Some(fake_set_size) };+        let sizes = || SET_SIZES.lock().unwrap().clone();+        let sizer = Arc::new(GuiSizer::new(320, 240));+        // `can_resize` false: nothing is negotiated.+        sizer.set_target(Some(target), false);+        assert_eq!(sizer.constrain(500.0, 400.0), None);+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));+        assert!(sizes().is_empty());++        sizer.set_target(Some(target), true);+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "adjust_size shapes the drag");+        let mut set = None;+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));+        assert_eq!(set, Some((800.0, 100.0)), "the window follows the adjusted size");+        assert_eq!(sizes(), vec![(800, 100)]);+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        assert_eq!(sizes(), vec![(800, 100), (640, 480)], "the same size is not repeated");++        // A plugin `request_resize` being applied: its window notifications are ignored.+        {+            let _applying = sizer.applying();+            assert_eq!(sizer.constrain(300.0, 300.0), None);+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));+            sizer.set_last(300, 300);+        }+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));+        assert_eq!(sizes().len(), 2);++        // A plugin that resizes its parent from `set_size` doesn't start a loop (or deadlock).+        *NESTED.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));+        *NESTED.lock().unwrap() = None;+        assert_eq!(sizes(), vec![(800, 100), (640, 480), (400, 400)]);++        // A plugin that refuses the size: the window goes back to the last accepted one.+        REFUSE.store(true, Ordering::Relaxed);+        let mut set = None;+        sizer.user_resized(500.0, 500.0, |w, h| set = Some((w, h)));+        REFUSE.store(false, Ordering::Relaxed);+        assert_eq!(set, Some((400.0, 400.0)));+        assert_eq!(sizer.last(), (400, 400));+        SET_SIZES.lock().unwrap().pop();++        // Closing the editor from inside `set_size` neither deadlocks nor leaves the plugin+        // reachable: the removal is applied when the call returns.+        *CLOSE_INSIDE.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));+        assert!(!sizer.resizable());+        assert!(sizer.target.lock().unwrap().is_none());+        sizer.set_target(Some(target), true);++        // A plugin without `adjust_size` gets the user's size as is.+        sizer.set_target(Some(SizeTarget { adjust_size: None, ..target }), true);+        assert_eq!(sizer.constrain(5000.0, 10.0), Some((5000.0, 10.0)));++        // Once the GUI goes, nothing reaches the plugin any more.+        sizer.set_target(None, true);+        assert!(!sizer.resizable());+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no plugin"));+        assert_eq!(sizes().len(), 4);+    }++    #[test]+    fn sizing_moves_only_the_dragged_edges() {+        let r = (100, 100, 500, 400);+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");+    }+     #[test]     fn the_transport_reaches_clap_in_fixed_point() {         let t = Transport {diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs--- a/crates/clap-host/src/lib.rs+++ b/crates/clap-host/src/lib.rs@@ -22,8 +22,10 @@ //! floating window ([`Plugin::open_editor`], or [`Plugin::editor`] for a handle usable on the main //! thread while the instance processes on the audio thread). Plugins that offer only an embedded //! editor get a host window of SoundCraft's on Windows (resized on `request_resize`, closed by-//! its title bar); elsewhere they are not hosted yet, and SoundCraft's generic parameter editor-//! still works for them.+//! its title bar; when the plugin `can_resize` the window gets a sizing border, and user resizes+//! are shaped by `adjust_size` and applied with `set_size`, the window going back to the last+//! size when the plugin refuses one); elsewhere they are not hosted yet, and SoundCraft's+//! generic parameter editor still works for them. //! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes //! and are reported by the editor's `idle`. diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml--- a/crates/vst3-host/Cargo.toml+++ b/crates/vst3-host/Cargo.toml@@ -20,7 +20,7 @@ libloading = "0.9" # The macOS editor host window (an NSWindow whose content view the plugin's IPlugView attaches to). [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6"-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSNotification", "NSObject", "NSString"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSGraphics"] }  # The Windows editor host window (a top-level HWND the plugin view's child window lives in).diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs--- a/crates/vst3-host/src/ffi.rs+++ b/crates/vst3-host/src/ffi.rs@@ -1763,6 +1763,8 @@ fn split_state(data: &[u8]) -> Option<(&[u8], &[u8])> { pub(crate) struct PlugFrame {     /// The host window (an `NSWindow*` on macOS), not owned; null when there is none.     window: std::sync::atomic::AtomicPtr<c_void>,+    /// Shared with the host window's resize handling (delegate / window procedure).+    sizer: Arc<ViewSizer>, }  impl Class for PlugFrame {@@ -1771,11 +1773,21 @@ impl Class for PlugFrame {  impl IPlugFrameTrait for PlugFrame {     unsafe fn resizeView(&self, view: *mut sb::IPlugView, new_size: *mut sb::ViewRect) -> sb::tresult {+        // Like Steinberg's editorhost: only the attached view may resize the window, and not+        // again from inside its own resize (a view that re-requests a size from `onSize` would+        // otherwise recurse until the stack overflows).+        if view.is_null() || view != self.sizer.attached() {+            return sb::kInvalidArgument;+        }         // SAFETY: in-pointer from the plugin (or null, handled).         let Some(r) = (unsafe { new_size.as_mut() }) else { return sb::kInvalidArgument };+        let Some(_resizing) = self.sizer.plugin_resize() else { return sb::kResultFalse };         let (w, h) = rect_size(r);+        // The window notifications this resize causes are not user resizes: don't negotiate them.+        let _guard = self.sizer.applying();         window::resize(self.window.load(Ordering::Relaxed), w, h);-        // SAFETY: the plugin passes its own live view (or null, handled).+        self.sizer.set_last(w, h);+        // SAFETY: the plugin passes its own live view (checked against the attached one above).         if let Some(v) = unsafe { vst3::ComRef::from_raw(view) } {             // SAFETY: valid view; `r` is the plugin's own writable rect.             unsafe { v.onSize(r) };@@ -1791,6 +1803,220 @@ fn rect_size(r: &sb::ViewRect) -> (f64, f64) {     (f64::from(w), f64::from(h)) } +/// A view rect of `w`×`h` at the origin (non-finite or out-of-range sizes are clamped).+fn size_rect(w: f64, h: f64) -> sb::ViewRect {+    let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 16_384.0) as i32 } else { 1 };+    sb::ViewRect { left: 0, top: 0, right: px(w), bottom: px(h) }+}++type ViewSlot = Option<ComPtr<sb::IPlugView>>;++/// Negotiates user resizes of a host window with its plugin view (VST3: the host asks+/// `checkSizeConstraint`, sizes the window to the answer, then calls `onSize`).+///+/// Shared by the plug frame (plugin-initiated `resizeView`) and the platform window's resize+/// handling (an `NSWindowDelegate` on macOS, the window procedure on Windows). Everything runs on+/// the main thread; the view lock only guards against the instance clearing the view from another+/// thread while a resize calls into it. Loops are cut in two places: while the host applies a+/// size (a plugin `resizeView`, or a user resize being constrained and handed to `onSize`), the+/// window notifications this causes are ignored rather than negotiated again (`applying`), and a+/// `resizeView` from inside another one is refused (`plugin_resizing`).+pub(crate) struct ViewSizer {+    view: Mutex<ViewSlot>,+    /// The thread inside a call into the view under `view`'s lock (see `with_view`).+    holder: Mutex<Option<std::thread::ThreadId>>,+    /// A view change requested re-entrantly by the holder thread, applied when its call returns.+    pending: Mutex<Option<ViewSlot>>,+    /// The attached view (identity only, never dereferenced): the one allowed to `resizeView`.+    attached: std::sync::atomic::AtomicPtr<sb::IPlugView>,+    /// The view said `canResize`: user resizes are negotiated (the window has a resize handle).+    resizable: AtomicBool,+    /// Nesting depth of resizes being applied by the host.+    applying: AtomicU32,+    /// A plugin `resizeView` is in progress.+    plugin_resizing: AtomicBool,+    /// The size last given to the view (`width << 32 | height`), to skip repeated `onSize`.+    last: AtomicU64,+}++/// Ends an "applying" section of a [`ViewSizer`] when dropped.+pub(crate) struct Applying<'a>(&'a AtomicU32);++impl Drop for Applying<'_> {+    fn drop(&mut self) {+        // Paired with the increment in `ViewSizer::applying`: never below zero.+        self.0.fetch_sub(1, Ordering::Relaxed);+    }+}++/// Ends a plugin `resizeView` of a [`ViewSizer`] when dropped.+pub(crate) struct PluginResize<'a>(&'a AtomicBool);++impl Drop for PluginResize<'_> {+    fn drop(&mut self) {+        self.0.store(false, Ordering::Relaxed);+    }+}++impl ViewSizer {+    pub fn new(w: f64, h: f64) -> ViewSizer {+        let s = ViewSizer {+            view: Mutex::new(None),+            holder: Mutex::new(None),+            pending: Mutex::new(None),+            attached: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()),+            resizable: AtomicBool::new(false),+            applying: AtomicU32::new(0),+            plugin_resizing: AtomicBool::new(false),+            last: AtomicU64::new(0),+        };+        s.set_last(w, h);+        s+    }++    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)+    }++    fn pending(&self) -> std::sync::MutexGuard<'_, Option<ViewSlot>> {+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Runs `f` on the view under its lock (`None` without a view). A view change requested from+    /// inside `f` on this thread (a plugin pumping a nested event loop that closes the editor) is+    /// deferred until `f` returns instead of deadlocking on the lock.+    fn with_view<R>(&self, f: impl FnOnce(&ComPtr<sb::IPlugView>) -> R) -> Option<R> {+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);+        *self.holder() = Some(std::thread::current().id());+        let out = g.as_ref().map(f);+        *self.holder() = None;+        if let Some(v) = self.pending().take() {+            *g = v;+        }+        out+    }++    /// Replaces the view, or defers that when this thread is inside `with_view` (see there).+    /// Returns the previous view when it was replaced right away.+    fn replace_view(&self, view: ViewSlot) -> ViewSlot {+        self.attached.store(view.as_ref().map_or(std::ptr::null_mut(), |v| v.as_ptr()), Ordering::Relaxed);+        if *self.holder() == Some(std::thread::current().id()) {+            *self.pending() = Some(view);+            return None;+        }+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);+        std::mem::replace(&mut *g, view)+    }++    /// Names the view about to be attached: from now on it may `resizeView` (plugins often do+    /// from `attached`), before it is handed over with [`ViewSizer::set_view`].+    pub fn expect_view(&self, view: &ComPtr<sb::IPlugView>) {+        self.attached.store(view.as_ptr(), Ordering::Relaxed);+    }++    fn attached(&self) -> *mut sb::IPlugView {+        self.attached.load(Ordering::Relaxed)+    }++    /// Hands the attached view over (or takes it away again: `None`).+    pub fn set_view(&self, view: ViewSlot, resizable: bool) {+        self.resizable.store(resizable && view.is_some(), Ordering::Relaxed);+        drop(self.replace_view(view));+    }++    /// Takes the view away (stops negotiating) and hands its reference to the caller (`None`+    /// when the removal had to be deferred, see `with_view`).+    pub fn take_view(&self) -> ViewSlot {+        self.resizable.store(false, Ordering::Relaxed);+        self.replace_view(None)+    }++    pub fn resizable(&self) -> bool {+        self.resizable.load(Ordering::Relaxed)+    }++    /// Marks a host-applied resize; window notifications are ignored until the guard drops.+    pub fn applying(&self) -> Applying<'_> {+        self.applying.fetch_add(1, Ordering::Relaxed);+        Applying(&self.applying)+    }++    fn is_applying(&self) -> bool {+        self.applying.load(Ordering::Relaxed) > 0+    }++    /// Starts a plugin `resizeView`; `None` while one is already in progress.+    fn plugin_resize(&self) -> Option<PluginResize<'_>> {+        (!self.plugin_resizing.swap(true, Ordering::Relaxed)).then_some(PluginResize(&self.plugin_resizing))+    }++    fn set_last(&self, w: f64, h: f64) {+        let r = size_rect(w, h);+        self.last.store((u64::from(r.right as u32) << 32) | u64::from(r.bottom as u32), Ordering::Relaxed);+    }++    fn last(&self) -> (f64, f64) {+        let v = self.last.load(Ordering::Relaxed);+        (f64::from((v >> 32) as u32), f64::from(v as u32))+    }++    /// The size the view accepts instead of `w`×`h` (`checkSizeConstraint`), or `None` when no+    /// resize should be negotiated (the view can't resize, is gone, or a resize is being applied).+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {+        if !self.resizable() || self.is_applying() {+            return None;+        }+        let _guard = self.applying();+        self.with_view(|view| constrain_view(view, w, h))+    }++    /// The window's content was resized to `w`×`h` by the user (or the window manager): settles+    /// on the view's constrained size, makes the window follow it through `set_window` when it+    /// differs, and tells the view with `onSize`. Ignored while the host applies a resize itself.+    // Called by the macOS and Windows host windows only (and the tests).+    #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {+        if !self.resizable() || self.is_applying() {+            return;+        }+        let _guard = self.applying();+        self.with_view(|view| {+            let (cw, ch) = constrain_view(view, w, h);+            let want = size_rect(w, h);+            let mut r = size_rect(cw, ch);+            if (r.right, r.bottom) != (want.right, want.bottom) {+                set_window(cw, ch);+            }+            if self.last() != (cw, ch) {+                self.set_last(cw, ch);+                // SAFETY: valid, attached view (the editor link takes it away before `removed`,+                // deferred to here when that happens inside this call); main thread; `r` is a+                // writable local rect.+                unsafe { view.onSize(&mut r) };+            }+        });+    }+}++/// `checkSizeConstraint` for a proposed `w`×`h`: the plugin adjusts the rect in place to a size it+/// supports. A plugin that doesn't implement it leaves the rect alone (the proposal stands).+fn constrain_view(view: &ComPtr<sb::IPlugView>, w: f64, h: f64) -> (f64, f64) {+    let mut r = size_rect(w, h);+    // SAFETY: valid view; `r` is a writable local rect.+    unsafe { view.checkSizeConstraint(&mut r) };+    rect_size(&r)+}++/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.+#[cfg_attr(not(windows), allow(dead_code))]+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };+    (l, t, r, b)+}+ #[derive(Default)] struct EditorInner {     /// `None` once the instance is gone.@@ -1849,6 +2075,23 @@ impl EditorLink {         Ok((w as u32, h as u32))     } +    /// Creates the view and asks it how it would take a user resize to `w`×`h`, without+    /// attaching it: `None` when it can't resize (`canResize`), else the size `checkSizeConstraint`+    /// settles on. The same negotiation the host window runs, except that `canResize` and+    /// `checkSizeConstraint` are asked before `attached` (the window asks once attached), which+    /// some plugins answer differently. For diagnostics and tests.+    pub fn probe_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {+        let g = self.lock();+        let view = EditorLink::create_view(&g)?;+        // SAFETY: valid view.+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;+        let sizer = ViewSizer::new(f64::from(w), f64::from(h));+        sizer.set_view(Some(view), resizable);+        let out = sizer.constrain(f64::from(w), f64::from(h)).map(|(a, b)| (a as u32, b as u32));+        sizer.set_view(None, false);+        Ok(out)+    }+     /// Opens (or raises) the editor in a host window. Main thread only.     pub fn open(&self, title: &str) -> Result<(), String> {         if !window::on_main_thread() {@@ -1873,13 +2116,16 @@ impl EditorLink {         // SAFETY: valid view; `r` is writable. On failure the default size stays.         unsafe { view.getSize(&mut r) };         let (w, h) = rect_size(&r);-        let win = window::HostWindow::new(title, w, h)?;-        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()) });+        let mut win = window::HostWindow::new(title, w, h)?;+        let sizer = Arc::new(ViewSizer::new(w, h));+        win.set_sizer(&sizer);+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()), sizer: sizer.clone() });         if let Some(f) = frame.as_com_ref::<sb::IPlugFrame>() {             // SAFETY: valid view; the frame outlives the view's attachment (kept in `g.frame`             // and detached in `teardown`).             unsafe { view.setFrame(f.as_ptr()) };         }+        sizer.expect_view(&view);         // SAFETY: valid view; the parent is the live content view of `win`, of the type named.         if !ok(unsafe { view.attached(win.parent(), platform) }) {             // SAFETY: valid view; detach our frame again.@@ -1887,6 +2133,13 @@ impl EditorLink {             win.close();             return Err("the editor could not attach to its window".into());         }+        // Asked once attached: some plugins only know whether their editor resizes by then.+        // SAFETY: valid, attached view.+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;+        // The resize border first: the frame change it causes is not a user resize (the sizer+        // negotiates only once it has the view).+        win.set_resizable(resizable);+        sizer.set_view(Some(view.clone()), resizable);         win.raise();         g.view = Some(view);         g.frame = Some(frame);@@ -1895,6 +2148,10 @@ impl EditorLink {     }      fn teardown(g: &mut EditorInner) {+        // No more user resizes reach the view once it is being removed.+        if let Some(f) = &g.frame {+            f.sizer.set_view(None, false);+        }         if let Some(view) = g.view.take() {             // SAFETY: valid, attached view; main thread (callers check). `removed` before the             // window goes, and the frame is detached before it is released.@@ -2033,9 +2290,11 @@ impl EditorLink {                     std::mem::forget(v);                     leaked = true;                 }-                // `HostWindow` has no destructor: dropping it leaks its retain on purpose.+                // `HostWindow` has no destructor: dropping it leaks its retain on purpose. The+                // leaked window keeps its resize handling, so cut it off from the view first.                 g.window = None;                 if let Some(f) = g.frame.take() {+                    std::mem::forget(f.sizer.take_view());                     std::mem::forget(f);                 }             }@@ -2049,11 +2308,85 @@ impl EditorLink { /// The platform host window for plugin views. #[cfg(target_os = "macos")] mod window {+    use super::ViewSizer;     use objc2::rc::Retained;-    use objc2::{MainThreadMarker, MainThreadOnly};-    use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};-    use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};+    use objc2::runtime::ProtocolObject;+    use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};+    use objc2_app_kit::{+        NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowDelegate, NSWindowStyleMask,+    };+    use objc2_foundation::{NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};     use std::ffi::{c_char, c_void};+    use std::sync::Arc;++    define_class!(+        // SAFETY: NSObject has no subclassing requirements, and `ResizeDelegate` implements no+        // `Drop` (its ivar, an `Arc`, is dropped by objc2 when the object is deallocated).+        // No `#[name]`: objc2 then generates a crate- and version-specific name and reuses an+        // already registered class of that name (e.g. a second copy of this crate in the process)+        // instead of panicking.+        #[unsafe(super(NSObject))]+        #[thread_kind = MainThreadOnly]+        #[ivars = Arc<ViewSizer>]+        struct ResizeDelegate;++        // SAFETY: NSObjectProtocol has no safety requirements.+        unsafe impl NSObjectProtocol for ResizeDelegate {}++        // SAFETY: the methods below match the NSWindowDelegate selectors' signatures.+        unsafe impl NSWindowDelegate for ResizeDelegate {+            /// Live user resizes: the window only ever takes a size the view accepts.+            #[unsafe(method(windowWillResize:toSize:))]+            fn window_will_resize(&self, sender: &NSWindow, frame_size: NSSize) -> NSSize {+                // Our own reference: the sizer stays alive even if the plugin closes the editor+                // from a nested event loop during this call.+                let sizer = self.ivars().clone();+                constrained_frame(&sizer, sender, NSRect::new(NSPoint::new(0.0, 0.0), frame_size)).size+            }++            /// The zoom button: zoom to the largest size the view accepts within the default+            /// (screen) frame, keeping the window's top edge.+            #[unsafe(method(windowWillUseStandardFrame:defaultFrame:))]+            fn window_standard_frame(&self, window: &NSWindow, default_frame: NSRect) -> NSRect {+                let sizer = self.ivars().clone();+                let mut frame = constrained_frame(&sizer, window, default_frame);+                frame.origin.y = default_frame.origin.y + default_frame.size.height - frame.size.height;+                frame+            }++            /// Every completed resize (drags, zoom): settles on a constrained size and tells the+            /// view. Resizes the host applies itself are ignored by the sizer.+            #[unsafe(method(windowDidResize:))]+            fn window_did_resize(&self, notification: &NSNotification) {+                let sizer = self.ivars().clone();+                let Some(window) = notification.object().and_then(|o| o.downcast::<NSWindow>().ok()) else { return };+                let Some(view) = window.contentView() else { return };+                let size = view.frame().size;+                sizer.user_resized(size.width, size.height, |w, h| window.setContentSize(NSSize::new(w, h)));+            }+        }+    );++    /// `frame` with its content size constrained by the view (unchanged when nothing is+    /// negotiated); the origin stays.+    fn constrained_frame(sizer: &ViewSizer, window: &NSWindow, frame: NSRect) -> NSRect {+        let content = window.contentRectForFrameRect(frame);+        match sizer.constrain(content.size.width, content.size.height) {+            Some((w, h)) => {+                let size = window.frameRectForContentRect(NSRect::new(content.origin, NSSize::new(w, h))).size;+                NSRect::new(frame.origin, size)+            }+            None => frame,+        }+    }++    impl ResizeDelegate {+        fn new(mtm: MainThreadMarker, sizer: Arc<ViewSizer>) -> Retained<ResizeDelegate> {+            let this = ResizeDelegate::alloc(mtm).set_ivars(sizer);+            // SAFETY: `init` of NSObject on a freshly allocated instance whose ivars are set.+            unsafe { msg_send![super(this), init] }+        }+    }      pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeNSView); @@ -2065,10 +2398,12 @@ mod window {     /// the plugin view's parent. Holds one retain of the window; main thread only.     pub struct HostWindow {         window: *mut NSWindow,+        /// The window's delegate (one retain; the window only keeps a weak reference), or null.+        delegate: *mut ResizeDelegate,     } -    // SAFETY: the pointer is only dereferenced on the main thread (every method checks-    // `MainThreadMarker`); the struct merely carries the retain between threads' locks.+    // SAFETY: the pointers are only dereferenced on the main thread (every method checks+    // `MainThreadMarker`); the struct merely carries the retains between threads' locks.     unsafe impl Send for HostWindow {}      impl HostWindow {@@ -2099,7 +2434,38 @@ mod window {             window.setExcludedFromWindowsMenu(true);             window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);             window.center();-            Ok(HostWindow { window: Retained::into_raw(window) })+            Ok(HostWindow { window: Retained::into_raw(window), delegate: std::ptr::null_mut() })+        }++        /// Routes the window's resizes through `sizer` (an `NSWindowDelegate`). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {+            let (Some(mtm), Some(w)) = (MainThreadMarker::new(), self.get()) else { return };+            if !self.delegate.is_null() {+                return;+            }+            let delegate = ResizeDelegate::new(mtm, sizer.clone());+            w.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));+            self.delegate = Retained::into_raw(delegate);+        }++        /// Gives the window a resize handle (the view said `canResize`), or takes it away.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(w) = self.get() else { return };+            let style = w.styleMask();+            let style = if resizable { style | NSWindowStyleMask::Resizable } else { style & !NSWindowStyleMask::Resizable };+            if style == w.styleMask() {+                return;+            }+            let size = w.contentView().map(|v| v.frame().size);+            w.setStyleMask(style);+            // A resizable window gets a zoom button; keep it out of full screen (the editor is a+            // transient panel, and the sizer would fight a full-screen size the view refuses).+            if resizable {+                w.setCollectionBehavior(w.collectionBehavior() | NSWindowCollectionBehavior::FullScreenNone);+            }+            if let Some(size) = size {+                w.setContentSize(size);+            }         }          /// The raw window pointer (for the plug frame's resizes).@@ -2124,12 +2490,20 @@ mod window {          pub fn close(self) {             if let Some(w) = self.get() {+                w.setDelegate(None);                 w.orderOut(None);                 w.close();             }             if MainThreadMarker::new().is_some() {                 // SAFETY: balances the retain taken in `new`; nothing uses the pointer after this.                 drop(unsafe { Retained::from_raw(self.window) });+                // SAFETY: null, or balances the retain taken in `set_sizer`; the window no longer+                // refers to it (delegate cleared above).+                if let Some(d) = unsafe { Retained::from_raw(self.delegate) } {+                    // Released when the event loop's autorelease pool drains, not now: `close` may+                    // run inside one of the delegate's own methods (a plugin pumping a nested loop).+                    let _ = Retained::autorelease_ptr(d);+                }             }         }     }@@ -2152,15 +2526,18 @@ mod window { /// the window. Messages are dispatched by the app's own event loop on the main thread. #[cfg(windows)] mod window {+    use super::{ViewSizer, sizing_rect};     use std::ffi::{c_char, c_void};-    use std::sync::OnceLock;+    use std::sync::{Arc, OnceLock};     use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};     use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;     use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;     use windows_sys::Win32::UI::WindowsAndMessaging::{         AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,-        GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,-        SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,     };      pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND);@@ -2176,11 +2553,83 @@ mod window {      const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; +    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released+    /// on `WM_NCDESTROY`), borrowed for one message.+    fn window_sizer(hwnd: HWND) -> Option<Arc<ViewSizer>> {+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const ViewSizer;+        if p.is_null() {+            return None;+        }+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it+        // even when a plugin destroys the window from a nested event loop meanwhile.+        unsafe { Arc::increment_strong_count(p) };+        // SAFETY: balances the increment just made.+        Some(unsafe { Arc::from_raw(p) })+    }++    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {+            return None;+        }+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));+        Some((fw.max(0), fh.max(0)))+    }++    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {+        match frame_size(hwnd) {+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),+            None => {+                // SAFETY: a window of this thread; style values are only read.+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };+                outer_size(style, ex, w, h)+            }+        }+    }+     unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {-        if msg == WM_CLOSE {-            // SAFETY: `hwnd` is the window this procedure was called for.-            unsafe { ShowWindow(hwnd, SW_HIDE) };-            return 0;+        match msg {+            WM_CLOSE => {+                // SAFETY: `hwnd` is the window this procedure was called for.+                unsafe { ShowWindow(hwnd, SW_HIDE) };+                return 0;+            }+            // Live user resizes: keep the dragged edges at a size the view accepts.+            WM_SIZING => {+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));+                        return 1;+                    }+                }+            }+            // Every completed resize: settle on the constrained size and tell the view.+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {+                if let Some(s) = window_sizer(hwnd) {+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));+                }+            }+            WM_NCDESTROY => {+                // SAFETY: the window's last message: take the user data back and release the+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const ViewSizer;+                if !p.is_null() {+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.+                    drop(unsafe { Arc::from_raw(p) });+                }+            }+            _ => {}         }         // SAFETY: default handling of a message for the window it was sent to.         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }@@ -2269,6 +2718,8 @@ mod window {             if hwnd.is_null() {                 return Err("cannot create the editor window".into());             }+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.+            resize(hwnd, w, h);             Ok(HostWindow { hwnd })         } @@ -2277,6 +2728,42 @@ mod window {             self.hwnd         } +        /// Routes the window's resizes through `sizer` (the window procedure reads it from the+        /// window's user data). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {+            let Some(h) = self.live() else { return };+            if window_sizer(h).is_some() {+                return;+            }+            let p = Arc::into_raw(sizer.clone());+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`+            // releases it.+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };+        }++        /// Gives the window a sizing border (the view said `canResize`), or takes it away,+        /// keeping the client size.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(h) = self.live() else { return };+            // SAFETY: a live window of this thread; the style is only read.+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {+                return;+            }+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };+            // SAFETY: a live window of this thread; `c` is writable.+            unsafe { GetClientRect(h, &mut c) };+            // SAFETY: as above; only the sizing-border bit of the style changes.+            unsafe {+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };+                SetWindowLongPtrW(h, GWL_STYLE, style as _);+            }+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };+            // The new frame took room from the client area: give it back.+            resize(h, f64::from(c.right), f64::from(c.bottom));+        }+         /// The `HWND` the plugin view creates its child window in.         pub fn parent(&self) -> *mut c_void {             self.live().unwrap_or(std::ptr::null_mut())@@ -2311,10 +2798,8 @@ mod window {         if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {             return;         }-        // SAFETY: a live window of this thread; style values are read, not changed.-        let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };-        let (ow, oh) = outer_size(style, ex, w, h);-        // SAFETY: as above; only the size changes.+        let (ow, oh) = outer_for(window, w, h);+        // SAFETY: a live window of this thread; only the size changes.         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };     } }@@ -2339,6 +2824,8 @@ mod window {         pub fn raw(&self) -> *mut c_void {             std::ptr::null_mut()         }+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::ViewSizer>) {}+        pub fn set_resizable(&self, _resizable: bool) {}         pub fn parent(&self) -> *mut c_void {             std::ptr::null_mut()         }@@ -2407,6 +2894,199 @@ mod tests {         assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());     } +    /// A plugin view that accepts 200..=800 × 100..=600 and records what the host tells it. With+    /// `nested`, it calls back into the sizer from `onSize`, the way a plugin calling+    /// `resizeView` from `onSize` makes the window notify the host again.+    struct SizedView {+        sizes: Mutex<Vec<(i32, i32)>>,+        nested: Mutex<Option<Arc<ViewSizer>>>,+        /// Closes the editor (takes the view away) from inside `onSize`, once.+        close_inside: Mutex<Option<Arc<ViewSizer>>>,+        /// Re-requests its size through this frame from inside `onSize` (as `view`, once per+        /// `onSize`), recording what the host answered.+        frame: Mutex<Option<(ComPtr<sb::IPlugFrame>, usize)>>,+        answers: Mutex<Vec<sb::tresult>>,+    }++    impl SizedView {+        fn new() -> SizedView {+            SizedView {+                sizes: Mutex::new(Vec::new()),+                nested: Mutex::new(None),+                close_inside: Mutex::new(None),+                frame: Mutex::new(None),+                answers: Mutex::new(Vec::new()),+            }+        }+    }++    impl Class for SizedView {+        type Interfaces = (sb::IPlugView,);+    }++    impl IPlugViewTrait for SizedView {+        unsafe fn isPlatformTypeSupported(&self, _t: sb::FIDString) -> sb::tresult {+            sb::kResultTrue+        }+        unsafe fn attached(&self, _p: *mut c_void, _t: sb::FIDString) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn removed(&self) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn onWheel(&self, _d: f32) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn onKeyDown(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn onKeyUp(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn getSize(&self, _s: *mut sb::ViewRect) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn onSize(&self, s: *mut sb::ViewRect) -> sb::tresult {+            let r = unsafe { &*s };+            self.sizes.lock().unwrap().push((r.right - r.left, r.bottom - r.top));+            if let Some(sizer) = self.nested.lock().unwrap().clone() {+                sizer.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));+            }+            if let Some(sizer) = self.close_inside.lock().unwrap().take() {+                sizer.set_view(None, false);+            }+            let frame = self.frame.lock().unwrap().clone();+            if let Some((f, me)) = frame {+                let mut r = sb::ViewRect { left: 0, top: 0, right: 333, bottom: 222 };+                let answer = unsafe { f.resizeView(me as *mut sb::IPlugView, &mut r) };+                self.answers.lock().unwrap().push(answer);+            }+            sb::kResultOk+        }+        unsafe fn onFocus(&self, _s: sb::TBool) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn setFrame(&self, _f: *mut sb::IPlugFrame) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn canResize(&self) -> sb::tresult {+            sb::kResultTrue+        }+        unsafe fn checkSizeConstraint(&self, r: *mut sb::ViewRect) -> sb::tresult {+            let r = unsafe { &mut *r };+            r.right = r.left + (r.right - r.left).clamp(200, 800);+            r.bottom = r.top + (r.bottom - r.top).clamp(100, 600);+            sb::kResultTrue+        }+    }++    #[test]+    fn user_resizes_are_negotiated_with_the_view_without_loops() {+        let fake = ComWrapper::new(SizedView::new());+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));+        // Not resizable (yet): nothing is negotiated and the view isn't told.+        sizer.set_view(Some(view.clone()), false);+        assert_eq!(sizer.constrain(500.0, 400.0), None);+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));+        assert!(fake.sizes.lock().unwrap().is_empty());++        sizer.set_view(Some(view), true);+        assert_eq!(sizer.constrain(500.0, 400.0), Some((500.0, 400.0)));+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "checkSizeConstraint adjusts the drag");+        // A size the view refuses: the window is set back to the constrained size, then `onSize`.+        let mut set = None;+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));+        assert_eq!(set, Some((800.0, 100.0)));+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100)]);+        // An accepted size leaves the window alone; the same size again is not repeated.+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100), (640, 480)]);++        // Notifications caused by a resize the host applies (a plugin `resizeView`) are ignored…+        {+            let _applying = sizer.applying();+            assert_eq!(sizer.constrain(300.0, 300.0), None);+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));+            sizer.set_last(300.0, 300.0);+        }+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);+        // …and the plugin's new size is remembered: the window notification for it is a no-op.+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);++        // A view that resizes itself from `onSize` doesn't start a loop (or deadlock).+        *fake.nested.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().last(), Some(&(400, 400)));+        assert_eq!(fake.sizes.lock().unwrap().len(), 3, "the nested notification was not negotiated");+        *fake.nested.lock().unwrap() = None;++        // Closing the editor from inside `onSize` (a plugin pumping a nested event loop) neither+        // deadlocks nor leaves the view reachable: the removal is applied when the call returns.+        *fake.close_inside.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);+        assert!(!sizer.resizable());+        assert!(sizer.view.lock().unwrap().is_none(), "removed once onSize returned");+        assert!(sizer.attached().is_null());+        sizer.set_view(fake.to_com_ptr::<sb::IPlugView>(), true);++        // Once the view is taken away (editor closing), nothing reaches it any more.+        assert!(sizer.take_view().is_some());+        assert_eq!(sizer.constrain(500.0, 500.0), None);+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no view"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);+    }++    #[test]+    fn plugin_resizes_come_from_the_attached_view_and_do_not_recurse() {+        let fake = ComWrapper::new(SizedView::new());+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();+        let other = ComWrapper::new(SizedView::new());+        let other = other.to_com_ptr::<sb::IPlugView>().unwrap();+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));+        // No window (null): only the negotiation runs.+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()), sizer: sizer.clone() });+        let f = frame.to_com_ptr::<sb::IPlugFrame>().unwrap();+        let mut r = sb::ViewRect { left: 0, top: 0, right: 500, bottom: 400 };+        // SAFETY: calls through our own implementation with valid pointers (or null, handled).+        unsafe {+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument, "nothing attached yet");+            sizer.expect_view(&view);+            assert_eq!(f.resizeView(other.as_ptr(), &mut r), sb::kInvalidArgument, "not the attached view");+            assert_eq!(f.resizeView(std::ptr::null_mut(), &mut r), sb::kInvalidArgument);+            assert_eq!(f.resizeView(view.as_ptr(), std::ptr::null_mut()), sb::kInvalidArgument);+            // The attached view resizes; asking again from inside its `onSize` is refused instead+            // of recursing.+            *fake.frame.lock().unwrap() = Some((f.clone(), view.as_ptr() as usize));+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);+            assert_eq!(*fake.sizes.lock().unwrap(), vec![(500, 400)]);+            assert_eq!(*fake.answers.lock().unwrap(), vec![sb::kResultFalse]);+            assert_eq!(sizer.last(), (500.0, 400.0));+            *fake.frame.lock().unwrap() = None;+            // The guard is released: the next request goes through.+            let mut r = sb::ViewRect { left: 0, top: 0, right: 640, bottom: 480 };+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);+            assert_eq!(sizer.last(), (640.0, 480.0));+            // Detached: refused again.+            sizer.set_view(None, false);+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument);+        }+    }++    #[test]+    fn sizing_moves_only_the_dragged_edges() {+        let r = (100, 100, 500, 400);+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");+        assert_eq!(sizing_rect(1, r, (300, 300)), (200, 100, 500, 400), "left (height follows the constraint downwards)");+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");+    }+     #[test]     fn the_transport_fills_the_process_context() {         use sv::ProcessContext_::StatesAndFlags_::*;diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs--- a/crates/vst3-host/src/lib.rs+++ b/crates/vst3-host/src/lib.rs@@ -27,7 +27,15 @@ //! Plugin editors (`IPlugView`): on macOS the view is attached to the content `NSView` of a host //! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`); //! on Windows it is attached to a host `HWND` owned by the app's window (no taskbar button,-//! closing it hides it until the link tears it down).+//! closing it hides it until the link tears it down). When the view `canResize`, the window gets+//! a resize handle: user resizes are constrained live by `checkSizeConstraint` (the window+//! delegate's `windowWillResize:toSize:` / `WM_SIZING`) and applied with `onSize` once the window+//! has its new size (`windowDidResize:` / `WM_SIZE`); the zoom button zooms to a constrained+//! size. Window notifications caused by the host's own resizes (a plugin's `resizeView`, or+//! snapping the window to a constrained size) are not negotiated again, and like Steinberg's+//! editorhost, `resizeView` is accepted only from the attached view and refused (`kResultFalse`)+//! while another one is in progress, so neither side can make resizes recurse. Content scaling+//! (`IPlugViewContentScaleSupport`) is not hosted yet. //! [`Plugin::open_editor`] opens it directly; [`Plugin::editor`] returns a handle usable on the //! main thread while the instance processes on the audio thread (VST3's own threading model: //! the controller and its views live on the UI thread). Parameter edits made in the editordiff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs--- a/crates/vst3-host/src/plugin.rs+++ b/crates/vst3-host/src/plugin.rs@@ -149,6 +149,16 @@ impl Vst3Plugin {         self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe()     } +    /// How the editor would take a user resize of its window to `w`×`h`, without opening it:+    /// `None` when it can't be resized (`IPlugView::canResize`), else the size it constrains the+    /// request to (`checkSizeConstraint`), as the host window negotiates it. For diagnostics and+    /// tests; real plugins expect this on the main thread. The view is not attached, so a plugin+    /// that only knows its constraints once attached may accept any size here.+    #[doc(hidden)]+    pub fn editor_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {+        self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe_resize(w, h)+    }+     /// Re-reads every parameter value from the controller (after a state load) and drops     /// changes still queued from before it.     fn refresh_values(&mut self) {diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs--- a/crates/vst3-host/tests/editor_main_thread.rs+++ b/crates/vst3-host/tests/editor_main_thread.rs@@ -15,6 +15,9 @@ fn main() {     } else {         assert!(p.editor_size().is_err());     }+    // A resizable view: user resizes of the host window are negotiated with the view.+    assert_eq!(p.editor_resize(640, 480), Ok(Some((640, 480))), "a size the view accepts stands");+    assert_eq!(p.editor_resize(50, 2000), Ok(Some((200, 900))), "checkSizeConstraint clamps the request");     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");     assert!(!ed.is_open());     assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs--- a/crates/vst3-host/tests/fixture/src/lib.rs+++ b/crates/vst3-host/tests/fixture/src/lib.rs@@ -6,7 +6,8 @@ //!   9 hidden, 11 bypass. Starts at gain 0.5 (its state) although the declared default is 1.0. //!   Reports 3 samples of latency. State: the component stores the gain (one LE f64), the //!   controller the Freq value (one LE f64). Its controller has an editor view (`IPlugView`,-//!   320×240, no real drawing) supporting NSView/HWND/X11.+//!   320×240, no real drawing) supporting NSView/HWND/X11, resizable between 200×150 and+//!   1200×900 (`canResize`, `checkSizeConstraint` clamps into that range). //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also //!   its edit controller). No audio input, one stereo output, one event input; every output //!   sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and@@ -266,10 +267,14 @@ impl IPlugViewTrait for View {         kResultOk     }     unsafe fn canResize(&self) -> tresult {-        kResultFalse+        kResultTrue     }-    unsafe fn checkSizeConstraint(&self, _r: *mut ViewRect) -> tresult {-        kResultOk+    /// Resizable between 200×150 and 1200×900 (a proposal outside is clamped into that range).+    unsafe fn checkSizeConstraint(&self, r: *mut ViewRect) -> tresult {+        let Some(r) = (unsafe { r.as_mut() }) else { return kInvalidArgument };+        r.right = r.left + (r.right - r.left).clamp(200, 1200);+        r.bottom = r.top + (r.bottom - r.top).clamp(150, 900);+        kResultTrue     } } diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs--- a/crates/vst3-host/tests/it/fixture_e2e.rs+++ b/crates/vst3-host/tests/it/fixture_e2e.rs@@ -334,4 +334,5 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() {     // The synth's controller has no view.     let synth = soundcraft_vst3_host::instantiate_plugin(&id_of("Fixture Synth")).unwrap();     assert!(synth.editor_size().is_err());+    assert!(synth.editor_resize(640, 480).is_err()); }diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (resizable plugin editor windows; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -44,8 +44,8 @@ User report #152: a saved instrument level does not reach the bounce. | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | State saved in sessions, presets | ✅ | ✅ | | | Create/destroy off the audio thread | ✅ | ✅ | |-| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing |-| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing |+| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows (resizable when the plugin `can_resize`: `adjust_size`/`set_size`; unit-tested, not yet run on Windows) | Embedded on macOS/Linux missing |+| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor); resizable views (`canResize`) get a resizable window, user resizes negotiated through `checkSizeConstraint`/`onSize` (fixture-tested; not yet resized by hand with a commercial plugin) | Linux views missing; content scaling (`IPlugViewContentScaleSupport`) missing | | Editors: AU | — | ❌ | Cocoa view hosting missing | | Multiple editor pages | ✅ | 🟡 | #6 | | macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 |@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Editors: resizable VST3 editor windows (macOS, Windows) and embedded CLAP editor windows (Windows) follow the plugin's size constraints (fixture- and unit-tested) | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |