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
@@ -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 {@@ -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`. @@ -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).@@ -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_::*;@@ -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 editor@@ -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) {@@ -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");@@ -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 } } @@ -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()); }@@ -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) |