Browse / github.com/storytold/soundcraft / patch
CLAP host: embedded editors on macOS
macOS: CLAP plugins with only an embedded editor (e.g. FabFilter) can't open their GUI ## Summary On macOS, a CLAP plugin that offers only an embedded editor can't show its own GUI. **Open Plugin Editor** does nothing visible; the status line (Edit window only) says: > Pro-Q 4: this plugin offers only an embedded editor, which SoundCraft hosts on Windows only Many CLAP plugins only offer an embedded editor (`clap.gui` with `is_floating = false`), so on macOS they can only be used through the generic parameter sliders. VST3 editors work on macOS, so the same plugin works as VST3 but not as CLAP. ## Environment - SoundCraft 0.6.1 (main @ c0cb993), macOS (aarch64) - FabFilter
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1048, open)
- Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195- Size
- +1782 −67 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c3b8943cb9d9bf8cd540cc55
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/clap-host/Cargo.toml crates/clap-host/src/ffi.rs crates/clap-host/src/lib.rs crates/clap-host/tests/common/mod.rs crates/clap-host/tests/editor_main_thread.rs crates/clap-host/tests/fixture/src/lib.rs crates/clap-host/tests/it/fixture_e2e.rs crates/engine/tests/it/clap_e2e.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/gaps.md docs/plugin-parity.md
@@ -3661,6 +3661,9 @@ dependencies = [ "clap-sys", "libloading 0.9.0", "log",+ "objc2 0.6.5",+ "objc2-app-kit 0.3.2",+ "objc2-foundation 0.3.2", "serde", "soundcraft-dsp", "thiserror 2.0.21",@@ -13,6 +13,18 @@ serde.workspace = true thiserror.workspace = true log.workspace = true +# The macOS host window for embedded editors (an NSWindow whose content view the plugin's GUI+# attaches its NSView to).+[target.'cfg(target_os = "macos")'.dependencies]+objc2 = "0.6"+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 editor test inspects the host window (NSApp's window list, the plugin's view).+[target.'cfg(target_os = "macos")'.dev-dependencies]+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSScreen"] }+ # The Windows host window for embedded editors (a top-level HWND the plugin parents its GUI in). [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }@@ -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,201 @@ 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 and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), 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 and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), 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 and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), 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 and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), 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 +956,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 +982,7 @@ impl Default for GuiLink { created: false, visible: false, window: None,+ sizer: None, }), host: Arc::new(HostState::default()), }@@ -812,6 +1019,12 @@ impl GuiLink { } let mut g = self.lock(); let vt = GuiLink::vt(&g).ok_or_else(|| "this plugin has no editor".to_string())?;+ // The user closed the editor (its host window, or the plugin's floating window) since the+ // last `idle`: free it and open a fresh one rather than reviving a closed window.+ if g.created && (g.window.as_ref().is_some_and(|w| !w.is_visible()) || self.host.gui_closed.swap(false, Ordering::Relaxed)) {+ self.host.gui_destroyed.store(false, Ordering::Relaxed);+ GuiLink::destroy(&mut g);+ } if g.created { if let Some(show) = vt.show { // SAFETY: live plugin with a created GUI; main thread (checked).@@ -831,7 +1044,7 @@ impl GuiLink { if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } { return self.open_embedded(&mut g, vt, api, title); }- return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());+ return Err("this plugin offers only an embedded editor, which SoundCraft hosts on macOS and Windows only".into()); } self.host.gui_closed.store(false, Ordering::Relaxed); self.host.gui_destroyed.store(false, Ordering::Relaxed);@@ -874,8 +1087,10 @@ impl GuiLink { return Err("the plugin could not create its editor".into()); } g.created = true;- if let Some(f) = vt.set_scale {- // SAFETY: live plugin with a created GUI.+ if !cfg!(target_os = "macos")+ && let Some(f) = vt.set_scale+ {+ // SAFETY: live plugin with a created GUI. (Cocoa uses logical pixels: not called.) unsafe { f(g.plugin, 1.0) }; } let (mut w, mut h) = (640u32, 480u32);@@ -893,7 +1108,13 @@ impl GuiLink { return Err(e); } };- let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } };+ let handle = if cfg!(target_os = "macos") { clap_window_handle { cocoa: win.parent() } } else { clap_window_handle { win32: win.parent() } };+ if win.parent().is_null() {+ win.close();+ GuiLink::destroy(g);+ return Err("the editor window has no view to attach to".into());+ }+ let parent = clap_window { api: api.as_ptr(), specific: handle }; // SAFETY: live plugin with a created embedded GUI; `parent` names our live window and // outlives the call. if !unsafe { set_parent(g.plugin, &parent) } {@@ -911,14 +1132,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 +1222,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 +1272,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 +1689,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 +1716,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 +1881,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 +1891,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,16 +1961,247 @@ 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) }; } } +/// The macOS host window for embedded editors: a floating, transient `NSWindow` whose content view+/// is the `NSView` the plugin adds its GUI's view to. Closing it only orders it out; the editor+/// link notices in `idle`, destroys the GUI, then closes the window. Modelled on the VST3 host's+/// window (same behaviour, `GuiSizer` in place of its `ViewSizer`).+#[cfg(target_os = "macos")]+mod hostwin {+ use super::GuiSizer;+ use objc2::rc::Retained;+ 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_void;+ use std::sync::Arc;+ use std::sync::atomic::{AtomicBool, Ordering};++ pub const SUPPORTED: bool = true;++ /// The delegate's state: the sizer, and whether the user closed the window.+ struct Ivars {+ sizer: Arc<GuiSizer>,+ closed: Arc<AtomicBool>,+ }++ 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, and never collides with the VST3 host's delegate class.+ #[unsafe(super(NSObject))]+ #[thread_kind = MainThreadOnly]+ #[ivars = Ivars]+ 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 plugin 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().sizer.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 plugin 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().sizer.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 an adjusted size and tells the+ /// plugin (`set_size`). Resizes the host applies itself are ignored by the sizer.+ #[unsafe(method(windowDidResize:))]+ fn window_did_resize(&self, notification: &NSNotification) {+ let sizer = self.ivars().sizer.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)));+ }++ /// The user closed the window (the host's own `close` clears the delegate first).+ /// Tracked here rather than read from `isVisible`, which is also false while the app+ /// is inactive and AppKit has hidden the window (`hidesOnDeactivate`).+ #[unsafe(method(windowWillClose:))]+ fn window_will_close(&self, _notification: &NSNotification) {+ self.ivars().closed.store(true, Ordering::Relaxed);+ }+ }+ );++ /// `frame` with its content size adjusted by the plugin (unchanged when nothing is+ /// negotiated); the origin stays.+ fn constrained_frame(sizer: &GuiSizer, 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<GuiSizer>, closed: Arc<AtomicBool>) -> Retained<ResizeDelegate> {+ let this = ResizeDelegate::alloc(mtm).set_ivars(Ivars { sizer, closed });+ // SAFETY: `init` of NSObject on a freshly allocated instance whose ivars are set.+ unsafe { msg_send![super(this), init] }+ }+ }++ /// An `NSWindow` (titled, closable, floating above the app's windows) whose content view is+ /// the plugin GUI'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,+ /// Set by the delegate when the user closes the window.+ closed: Arc<AtomicBool>,+ }++ // 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 {+ fn get(&self) -> Option<&NSWindow> {+ MainThreadMarker::new()?;+ // SAFETY: we hold a retain of this window (from `new`) until `close`.+ unsafe { self.window.as_ref() }+ }++ pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {+ let mtm = MainThreadMarker::new().ok_or_else(|| "plugin editors must be opened on the main thread".to_string())?;+ let _app = NSApplication::sharedApplication(mtm);+ let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h));+ // Plugin editors are transient windows. In particular they must not be minimizable:+ // minimized NSWindows become persistent thumbnails in the macOS Dock.+ let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable;+ // SAFETY: a freshly allocated window on the main thread; valid rect and style.+ let window = unsafe {+ NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)+ };+ // SAFETY: we keep our own retain and release it in `close`, so AppKit must not+ // release the window when the user closes it.+ unsafe { window.setReleasedWhenClosed(false) };+ window.setTitle(&NSString::from_str(title));+ window.setLevel(NSFloatingWindowLevel);+ window.setHidesOnDeactivate(true);+ window.setExcludedFromWindowsMenu(true);+ window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);+ window.center();+ Ok(HostWindow { window: Retained::into_raw(window), delegate: std::ptr::null_mut(), closed: Arc::new(AtomicBool::new(false)) })+ }++ /// The raw window pointer (for resizes).+ pub fn raw(&self) -> *mut c_void {+ self.window.cast()+ }++ /// Routes the window's resizes through `sizer` (an `NSWindowDelegate`). Once per window.+ pub fn set_sizer(&mut self, sizer: &Arc<GuiSizer>) {+ let (Some(mtm), Some(w)) = (MainThreadMarker::new(), self.get()) else { return };+ if !self.delegate.is_null() {+ return;+ }+ let delegate = ResizeDelegate::new(mtm, sizer.clone(), self.closed.clone());+ w.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));+ self.delegate = Retained::into_raw(delegate);+ }++ /// Gives the window a resize handle (the plugin said `can_resize`), or takes it away,+ /// keeping the content size.+ 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 plugin refuses).+ if resizable {+ w.setCollectionBehavior(w.collectionBehavior() | NSWindowCollectionBehavior::FullScreenNone);+ }+ if let Some(size) = size {+ w.setContentSize(size);+ }+ }++ /// The content view (`NSView*`) the plugin adds its view to; null when unavailable.+ pub fn parent(&self) -> *mut c_void {+ self.get().and_then(|w| w.contentView()).map_or(std::ptr::null_mut(), |v| Retained::as_ptr(&v).cast_mut().cast())+ }++ pub fn raise(&self) {+ if let Some(w) = self.get() {+ w.makeKeyAndOrderFront(None);+ }+ }++ /// The window is open: not closed by the user. (Before the delegate is set, the window's+ /// own visibility.)+ pub fn is_visible(&self) -> bool {+ self.get().is_some_and(|w| if self.delegate.is_null() { w.isVisible() } else { !self.closed.load(Ordering::Relaxed) })+ }++ 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);+ }+ }+ }+ }++ /// Resizes a host window's content (`window` from [`HostWindow::raw`]); main thread only.+ pub fn resize(window: *mut c_void, w: f64, h: f64) {+ if MainThreadMarker::new().is_none() {+ return;+ }+ // SAFETY: null or a window the editor link keeps retained while the GUI is embedded.+ if let Some(win) = unsafe { window.cast::<NSWindow>().as_ref() } {+ win.setContentSize(NSSize::new(w, h));+ }+ }+}+ /// Other platforms: embedded editors are not hosted (floating ones still are).-#[cfg(not(windows))]+#[cfg(not(any(windows, target_os = "macos")))] mod hostwin { use std::ffi::c_void; @@ -1626,6 +2216,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 +2235,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 {@@ -21,9 +21,12 @@ //! [`Plugin::load_state`] as an opaque blob. Plugin editors (`clap.gui`) open as the plugin's own //! 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.+//! editor get a host window of SoundCraft's on macOS (an `NSWindow` whose content view is the+//! Cocoa parent; no `set_scale`, Cocoa uses logical pixels) and Windows (resized on+//! `request_resize`, closed by 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 (X11) 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`. @@ -5,6 +5,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; +/// How many plugins the fixture library exports (`tests/fixture/src/lib.rs`, `factory_count`).+pub const FIXTURE_PLUGINS: usize = 6;+ /// Builds the fixture once and returns a folder containing `Fixture.clap`. pub fn fixture_dir() -> &'static Path { static DIR: OnceLock<PathBuf> = OnceLock::new();@@ -40,7 +43,7 @@ pub fn fixture_dir() -> &'static Path { std::fs::create_dir_all(dir.join("vendor")).unwrap(); std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap(); }- assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);+ assert_eq!(soundcraft_clap_host::add_search_dir(&dir), FIXTURE_PLUGINS); dir }) }@@ -1,12 +1,15 @@ //! Plugin editors must be driven from the process main thread, which the standard test harness-//! never uses, so this test binary has its own `main` (`harness = false`). The fixture's GUI is-//! window-less: nothing appears on screen.+//! never uses, so this test binary has its own `main` (`harness = false`). The gain fixture's+//! floating GUI is window-less; the embedded fixture's GUI lives in a real host window (macOS and+//! Windows), which briefly appears on screen. Set `SOUNDCRAFT_SKIP_GUI_TESTS=1` to skip that part+//! (it is skipped anyway on macOS without a screen, e.g. outside a login session). mod common; use soundcraft_dsp::Plugin; const GAIN: &str = "clap:org.soundcraft.test.gain";+const EMBEDDED: &str = "clap:org.soundcraft.test.embedded"; fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> { let mut io = vec![vec![1.0f32; 64]; 2];@@ -44,5 +47,116 @@ fn main() { assert!(ed.open().is_err()); assert!(ed.idle().is_empty()); ed.close();+ embedded(); println!("editor_main_thread: ok"); }++/// The visible host window of the embedded fixture, its content size and its child view's size.+#[cfg(target_os = "macos")]+fn host_window() -> Option<(objc2::rc::Retained<objc2_app_kit::NSWindow>, (f64, f64), (f64, f64))> {+ let mtm = objc2::MainThreadMarker::new().expect("main thread");+ let w = objc2_app_kit::NSApplication::sharedApplication(mtm)+ .windows()+ .iter()+ .find(|w| w.isVisible() && w.title().to_string() == "Fixture Embedded")?;+ let content = w.contentView()?;+ let child = content.subviews().firstObject()?;+ let (c, v) = (content.frame().size, child.frame().size);+ Some((w, (c.width, c.height), (v.width, v.height)))+}++/// Real windows can be opened: not opted out, and (macOS) a screen to put them on.+fn gui_available() -> bool {+ if std::env::var_os("SOUNDCRAFT_SKIP_GUI_TESTS").is_some_and(|v| !v.is_empty() && v != "0") {+ return false;+ }+ #[cfg(target_os = "macos")]+ {+ let mtm = objc2::MainThreadMarker::new().expect("main thread");+ objc2_app_kit::NSScreen::mainScreen(mtm).is_some()+ }+ #[cfg(not(target_os = "macos"))]+ true+}++/// An embedded-only CLAP editor (no floating window): hosted in a window of ours on macOS and+/// Windows, refused elsewhere.+fn embedded() {+ let mut p = soundcraft_clap_host::create(EMBEDDED).expect("embedded fixture");+ p.prepare(48_000.0, 64, 2);+ let mut ed = p.editor().expect("editor handle");+ if !cfg!(any(target_os = "macos", windows)) {+ assert!(ed.open().is_err(), "embedded editors are not hosted here");+ return;+ }+ if !gui_available() {+ println!("editor_main_thread: no GUI session; embedded editor windows skipped");+ return;+ }+ objc_pool(|| {+ ed.open().expect("embedded editor opens");+ assert!(ed.is_open());+ #[cfg(target_os = "macos")]+ {+ use objc2_app_kit::NSWindowStyleMask;+ use objc2_foundation::NSSize;+ let (w, content, child) = host_window().expect("host window with the plugin's view");+ assert_eq!((content, child), ((400.0, 300.0), (400.0, 300.0)), "sized by get_size");+ assert!(w.styleMask().contains(NSWindowStyleMask::Resizable), "can_resize gives a resize handle");+ // The fixture's `show` asked for 300×200 (`request_resize`): applied by idle.+ let io = block(&mut p);+ assert!((io[0][10] - 0.25).abs() < 1e-6);+ assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);+ let (_, content, child) = host_window().expect("still open");+ assert_eq!((content, child), ((300.0, 200.0), (300.0, 200.0)), "request_resize applied");+ // A user resize is negotiated: adjust_size clamps it, the window follows, set_size+ // resizes the plugin's view.+ w.setContentSize(NSSize::new(1000.0, 1000.0));+ let (_, content, child) = host_window().expect("still open");+ assert_eq!((content, child), ((800.0, 600.0), (800.0, 600.0)), "user resize adjusted");+ // Hidden (as AppKit does when the app deactivates) is not closed.+ w.orderOut(None);+ assert!(ed.idle().is_empty());+ assert!(ed.is_open(), "a hidden editor stays open");+ ed.open().expect("raise");+ assert!(host_window().is_some(), "raised again");+ // The user closes the window: the editor notices and frees the GUI in idle.+ w.performClose(None);+ assert!(!ed.is_open());+ assert!(ed.idle().is_empty());+ assert!(host_window().is_none());+ // Closed by the user and reopened before the next idle: a fresh editor, not the+ // closed window brought back (and then freed by idle).+ ed.open().expect("reopen");+ let (w, _, _) = host_window().expect("reopened");+ w.performClose(None);+ assert!(!ed.is_open());+ ed.open().expect("reopen before idle");+ assert!(ed.idle().is_empty());+ assert!(ed.is_open(), "the reopened editor survives idle");+ assert!(host_window().is_some());+ }+ ed.close();+ assert!(!ed.is_open());+ // Reopen, then drop the instance with the editor open: the GUI goes first (the fixture+ // asserts that), and the window with it.+ ed.open().expect("reopen");+ assert!(ed.is_open());+ drop(p);+ assert!(!ed.is_open());+ #[cfg(target_os = "macos")]+ assert!(host_window().is_none(), "the host window closed with the instance");+ assert!(ed.open().is_err());+ });+}++/// Runs `f` inside an autorelease pool (the app's event loop provides one; this test has none).+#[cfg(target_os = "macos")]+fn objc_pool(f: impl FnOnce()) {+ objc2::rc::autoreleasepool(|_| f());+}++#[cfg(not(target_os = "macos"))]+fn objc_pool(f: impl FnOnce()) {+ f();+}@@ -14,6 +14,11 @@ //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, //! reported as an output parameter event by the next `process`.+//!+//! - `org.soundcraft.test.embedded`: the gain plugin with an embedded-only `clap.gui` instead+//! (Cocoa on macOS): `set_parent` adds a child `NSView` to the host's view, `get_size` starts at+//! 400×300, `can_resize` is true, `adjust_size` clamps to 200..=800 × 150..=600 and `set_size`+//! resizes the child view, so a test can read the negotiated size off the view. #![allow(clippy::missing_safety_doc, non_upper_case_globals)] use clap_sys::audio_buffer::clap_audio_buffer;@@ -75,6 +80,19 @@ static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor { features: GAIN_FEATURES.0.as_ptr(), }; +static EMBEDDED_DESC: clap_plugin_descriptor = clap_plugin_descriptor {+ clap_version: CLAP_VERSION,+ id: c"org.soundcraft.test.embedded".as_ptr(),+ name: c"Fixture Embedded".as_ptr(),+ vendor: c"SoundCraft tests".as_ptr(),+ url: std::ptr::null(),+ manual_url: std::ptr::null(),+ support_url: std::ptr::null(),+ version: c"1.0.0".as_ptr(),+ description: std::ptr::null(),+ features: GAIN_FEATURES.0.as_ptr(),+};+ static SYNTH_DESC: clap_plugin_descriptor = clap_plugin_descriptor { clap_version: CLAP_VERSION, id: c"org.soundcraft.test.synth".as_ptr(),@@ -102,6 +120,10 @@ struct Fx { gui_created: bool, gui_visible: bool, gui_edit_pending: bool,+ /// The embedded-only GUI variant.+ embedded: bool,+ /// The embedded GUI's child view (macOS), owned (one retain) until `gui_destroy`.+ gui_view: *mut c_void, } unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {@@ -469,14 +491,19 @@ unsafe extern "C" fn state_load(p: *const clap_plugin, stream: *const clap_istre static STATE: clap_plugin_state = clap_plugin_state { save: Some(state_save), load: Some(state_load) }; -unsafe extern "C" fn gui_is_api_supported(_p: *const clap_plugin, api: *const c_char, floating: bool) -> bool {+unsafe extern "C" fn gui_is_api_supported(p: *const clap_plugin, api: *const c_char, floating: bool) -> bool { assert!(!api.is_null());- floating+ if unsafe { fx(p) }.embedded {+ let api = unsafe { CStr::from_ptr(api) };+ !floating && (api == CLAP_WINDOW_API_COCOA || api == CLAP_WINDOW_API_WIN32 || api == CLAP_WINDOW_API_X11)+ } else {+ floating+ } } unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floating: bool) -> bool { let f = unsafe { fx(p) };- assert!(floating && !f.gui_created, "create");+ assert!(floating != f.embedded && !f.gui_created, "create"); f.gui_created = true; true }@@ -484,14 +511,68 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa unsafe extern "C" fn gui_destroy(p: *const clap_plugin) { let f = unsafe { fx(p) }; assert!(f.gui_created, "destroy without create");+ if !f.gui_view.is_null() {+ unsafe { cocoa::remove_and_release(f.gui_view) };+ f.gui_view = std::ptr::null_mut();+ } f.gui_created = false; f.gui_visible = false; } -unsafe extern "C" fn gui_set_scale(_p: *const clap_plugin, scale: f64) -> bool {+unsafe extern "C" fn gui_set_scale(p: *const clap_plugin, scale: f64) -> bool {+ // CLAP: Cocoa uses logical pixels, so a host never sets a scale there.+ assert!(!(cfg!(target_os = "macos") && unsafe { fx(p) }.embedded), "set_scale on Cocoa"); scale > 0.0 } +unsafe extern "C" fn gui_get_size(p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+ assert!(unsafe { fx(p) }.gui_created);+ unsafe {+ *w = 400;+ *h = 300;+ }+ true+}++unsafe extern "C" fn gui_can_resize(_p: *const clap_plugin) -> bool {+ true+}++unsafe extern "C" fn gui_adjust_size(_p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+ unsafe {+ *w = (*w).clamp(200, 800);+ *h = (*h).clamp(150, 600);+ }+ true+}++unsafe extern "C" fn gui_set_size(p: *const clap_plugin, w: u32, h: u32) -> bool {+ let f = unsafe { fx(p) };+ assert!(f.gui_created, "set_size without a GUI");+ if !(200..=800).contains(&w) || !(150..=600).contains(&h) {+ return false;+ }+ if !f.gui_view.is_null() {+ unsafe { cocoa::set_size(f.gui_view, f64::from(w), f64::from(h)) };+ }+ true+}++unsafe extern "C" fn gui_set_parent(p: *const clap_plugin, window: *const clap_window) -> bool {+ let f = unsafe { fx(p) };+ assert!(f.gui_created && f.gui_view.is_null(), "set_parent");+ let window = unsafe { &*window };+ if cfg!(target_os = "macos") {+ assert_eq!(unsafe { CStr::from_ptr(window.api) }, CLAP_WINDOW_API_COCOA);+ let parent = unsafe { window.specific.cocoa };+ assert!(!parent.is_null(), "no parent view");+ f.gui_view = unsafe { cocoa::add_child(parent, 400.0, 300.0) };+ !f.gui_view.is_null()+ } else {+ true+ }+}+ unsafe extern "C" fn gui_suggest_title(p: *const clap_plugin, title: *const c_char) { assert!(unsafe { fx(p) }.gui_created); assert!(!unsafe { CStr::from_ptr(title) }.to_bytes().is_empty());@@ -515,6 +596,87 @@ unsafe extern "C" fn gui_hide(p: *const clap_plugin) -> bool { true } +static GUI_EMBEDDED: clap_plugin_gui = clap_plugin_gui {+ is_api_supported: Some(gui_is_api_supported),+ get_preferred_api: None,+ create: Some(gui_create),+ destroy: Some(gui_destroy),+ set_scale: Some(gui_set_scale),+ get_size: Some(gui_get_size),+ can_resize: Some(gui_can_resize),+ get_resize_hints: None,+ adjust_size: Some(gui_adjust_size),+ set_size: Some(gui_set_size),+ set_parent: Some(gui_set_parent),+ set_transient: None,+ suggest_title: None,+ show: Some(gui_show),+ hide: Some(gui_hide),+};++/// Just enough AppKit, through the Objective-C runtime, for the embedded GUI's child view.+#[cfg(target_os = "macos")]+mod cocoa {+ use std::ffi::{c_char, c_void};++ #[repr(C)]+ #[derive(Clone, Copy)]+ struct Rect {+ x: f64,+ y: f64,+ w: f64,+ h: f64,+ }++ #[link(name = "objc")]+ unsafe extern "C" {+ fn objc_getClass(name: *const c_char) -> *mut c_void;+ fn sel_registerName(name: *const c_char) -> *mut c_void;+ fn objc_msgSend();+ }++ #[link(name = "AppKit", kind = "framework")]+ unsafe extern "C" {}++ /// A new `NSView` of `w`×`h` added to `parent` (one retain, owned by the caller).+ pub unsafe fn add_child(parent: *mut c_void, w: f64, h: f64) -> *mut c_void {+ unsafe {+ let send0: unsafe extern "C" fn(*mut c_void, *mut c_void) -> *mut c_void = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+ let init: unsafe extern "C" fn(*mut c_void, *mut c_void, Rect) -> *mut c_void = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+ let send1: unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+ let view = send0(objc_getClass(c"NSView".as_ptr()), sel_registerName(c"alloc".as_ptr()));+ let view = init(view, sel_registerName(c"initWithFrame:".as_ptr()), Rect { x: 0.0, y: 0.0, w, h });+ send1(parent, sel_registerName(c"addSubview:".as_ptr()), view);+ view+ }+ }++ pub unsafe fn set_size(view: *mut c_void, w: f64, h: f64) {+ unsafe {+ let send: unsafe extern "C" fn(*mut c_void, *mut c_void, f64, f64) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+ send(view, sel_registerName(c"setFrameSize:".as_ptr()), w, h);+ }+ }++ pub unsafe fn remove_and_release(view: *mut c_void) {+ unsafe {+ let send0: unsafe extern "C" fn(*mut c_void, *mut c_void) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+ send0(view, sel_registerName(c"removeFromSuperview".as_ptr()));+ send0(view, sel_registerName(c"release".as_ptr()));+ }+ }+}++#[cfg(not(target_os = "macos"))]+mod cocoa {+ use std::ffi::c_void;+ pub unsafe fn add_child(_parent: *mut c_void, _w: f64, _h: f64) -> *mut c_void {+ std::ptr::null_mut()+ }+ pub unsafe fn set_size(_view: *mut c_void, _w: f64, _h: f64) {}+ pub unsafe fn remove_and_release(_view: *mut c_void) {}+}+ static GUI: clap_plugin_gui = clap_plugin_gui { is_api_supported: Some(gui_is_api_supported), get_preferred_api: None,@@ -546,6 +708,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> &NOTES as *const _ as *const c_void } else if id == CLAP_EXT_STATE && !synth { &STATE as *const _ as *const c_void+ } else if id == CLAP_EXT_GUI && unsafe { fx(p) }.embedded {+ &GUI_EMBEDDED as *const _ as *const c_void } else if id == CLAP_EXT_GUI && !synth { &GUI as *const _ as *const c_void } else {@@ -558,7 +722,7 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {} // ---- factory + entry ---- unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {- 5+ 6 } unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {@@ -568,14 +732,18 @@ unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *co 2 => &WIDE16_DESC, 3 => &WIDE17_DESC, 4 => &WIDE65_DESC,+ 5 => &EMBEDDED_DESC, _ => std::ptr::null(), } } unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin { let id = unsafe { CStr::from_ptr(id) };+ let embedded = id == c"org.soundcraft.test.embedded"; let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" { (&GAIN_DESC as *const _, false, None)+ } else if embedded {+ (&EMBEDDED_DESC as *const _, false, None) } else if id == c"org.soundcraft.test.synth" { (&SYNTH_DESC as *const _, true, None) } else if id == c"org.soundcraft.test.wide16" {@@ -613,6 +781,8 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const gui_created: false, gui_visible: false, gui_edit_pending: false,+ embedded,+ gui_view: std::ptr::null_mut(), }); let raw = Box::into_raw(b); unsafe {@@ -3,7 +3,7 @@ use soundcraft_dsp::{Category, Unit}; -use crate::common::fixture_dir;+use crate::common::{FIXTURE_PLUGINS, fixture_dir}; const GAIN: &str = "clap:org.soundcraft.test.gain"; const SYNTH: &str = "clap:org.soundcraft.test.synth";@@ -12,7 +12,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth"; fn scan_lists_both_fixture_plugins() { let dir = fixture_dir(); let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);- assert_eq!(found.len(), 5);+ assert_eq!(found.len(), FIXTURE_PLUGINS); let g = found.iter().find(|d| d.id == GAIN).unwrap(); assert_eq!(g.name, "Fixture Gain"); assert_eq!(g.vendor, "SoundCraft tests");@@ -40,7 +40,8 @@ fn install_fixture_now() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();- assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);+ // The plugins it exports are checked by id below (their count belongs to the clap-host tests).+ assert!(soundcraft_clap_host::add_search_dir(&dir) > 0); } #[test]@@ -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 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-05: embedded CLAP editors on macOS; previously trivial: G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**@@ -114,8 +114,8 @@ launch #142; Linux #45, #107. - **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views- (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP- GUIs; VST3 editors on Windows/Linux.+ (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). VST3 editors+ on Linux. - **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it). ### G-06. Memory-bound media@@ -221,6 +221,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | trivial | G-05: embedded CLAP editors are hosted on macOS (#485); VST3 editors already open on Windows | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |@@ -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 (embedded CLAP editors on macOS; previously 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 macOS (Cocoa parent view in a SoundCraft window; fixture-tested with a real window, not yet run with a commercial plugin) and Windows (unit-tested, not yet run on Windows), resizable when the plugin `can_resize`: `adjust_size`/`set_size` | Embedded on Linux (X11) 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,8 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Editors: embedded-only CLAP editors open in a SoundCraft window on macOS (#485), resizable like the Windows ones |+| 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) |