From b1cb6cb36873da5e224bfc7a6ed9a840c7793198 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:24:26 +0200 Subject: [PATCH 1/5] vst3-host: host plugin editors on Windows Adds a Windows implementation of the editor host window: a top-level HWND owned by the app's active window (no taskbar button), sized from the view's rect, resized on IPlugFrame::resizeView, hidden on close and torn down by the editor link's idle like on macOS. The main thread is the one Rust names "main", where the app runs its message loop. Also fixes two Windows-only build problems that kept the VST3 tests from running there: the fixture's bus flags (the vst3 bindings use i32 enums with MSVC) and an unused variable in a unix-only test. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/vst3-host/Cargo.toml | 4 + crates/vst3-host/src/ffi.rs | 175 ++++++++++++++++++- crates/vst3-host/src/lib.rs | 11 +- crates/vst3-host/tests/editor_main_thread.rs | 4 +- crates/vst3-host/tests/fixture/src/lib.rs | 4 +- crates/vst3-host/tests/fixture_e2e.rs | 6 +- 7 files changed, 194 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3228925..4b20fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3640,6 +3640,7 @@ dependencies = [ "soundcraft-dsp", "thiserror 2.0.21", "vst3", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 450d454..1c1583a 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -23,6 +23,10 @@ objc2 = "0.6" objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "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). +[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"] } + [[test]] name = "editor_main_thread" harness = false diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad53..5a25191 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1844,8 +1844,181 @@ mod window { } } +/// The Windows host window: a top-level window owned by the app's active window (so it stays +/// above it and has no taskbar button) whose client area is the plugin view's `HWND` parent. +/// Closing it only hides it; the editor link notices in `idle`, detaches the view, then destroys +/// the window. Messages are dispatched by the app's own event loop on the main thread. +#[cfg(windows)] +mod window { + use std::ffi::{c_char, c_void}; + use std::sync::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, + }; + + pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND); + + /// Rust names the process's first thread "main"; the app runs its UI (and message loop) there. + pub fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect() + } + + const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; + + 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; + } + // SAFETY: default handling of a message for the window it was sent to. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + /// Registers the window class once; the name lives for the whole process. + fn class_name() -> Option<&'static [u16]> { + static CLASS: OnceLock>> = OnceLock::new(); + CLASS + .get_or_init(|| { + let name = wide("SoundCraftVst3Editor"); + // SAFETY: null asks for this process's module handle. + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: instance, + hIcon: std::ptr::null_mut(), + // SAFETY: a system cursor (null instance + predefined id). + hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }, + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // SAFETY: a fully initialised class description whose strings outlive the call + // (the name is kept for the process lifetime in this `OnceLock`). + (unsafe { RegisterClassExW(&wc) } != 0).then_some(name) + }) + .as_deref() + } + + /// Outer window size for a client area of `w`×`h`. + fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) { + let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 }; + // SAFETY: `r` is a valid, writable rect. + unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) }; + (r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top)) + } + + /// Owns the window handle; destroyed in `close` (main thread only). + pub struct HostWindow { + hwnd: HWND, + } + + // SAFETY: the handle is only used on the main thread (every method checks); the struct + // merely carries it between threads' locks. + unsafe impl Send for HostWindow {} + + impl HostWindow { + fn live(&self) -> Option { + // SAFETY: `IsWindow` accepts any value and only reports whether it names a window. + (on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd) + } + + pub fn new(title: &str, w: f64, h: f64) -> Result { + if !on_main_thread() { + return Err("not on the main thread".into()); + } + let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?; + let title = wide(title); + let (ow, oh) = outer_size(STYLE, 0, w, h); + // SAFETY: main thread; the active window (or null) of this thread's queue. + let owner = unsafe { GetActiveWindow() }; + // SAFETY: registered class and NUL-terminated strings that outlive the call; the + // owner is null or a window of this thread. + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + title.as_ptr(), + STYLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + ow, + oh, + owner, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err("cannot create the editor window".into()); + } + Ok(HostWindow { hwnd }) + } + + /// The window handle (for the plug frame's resizes). + pub fn raw(&self) -> *mut c_void { + self.hwnd + } + + /// 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()) + } + + pub fn raise(&self) { + if let Some(h) = self.live() { + // SAFETY: a live window of this thread. + unsafe { + ShowWindow(h, SW_SHOW); + SetForegroundWindow(h); + } + } + } + + pub fn is_visible(&self) -> bool { + // SAFETY: a live window of this thread. + self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0) + } + + pub fn close(self) { + if let Some(h) = self.live() { + // SAFETY: our own live window; the view was detached before (see `teardown`). + unsafe { DestroyWindow(h) }; + } + } + } + + /// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only. + pub fn resize(window: *mut c_void, w: f64, h: f64) { + // SAFETY: `IsWindow` accepts any value. + 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. + unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) }; + } +} + /// Other platforms: no editor windows yet (opening reports "unsupported"). -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", windows)))] mod window { use std::ffi::{c_char, c_void}; diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 0cfd3ab..93b68a4 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -25,13 +25,15 @@ //! component state is accepted too) and re-reads the parameter values. //! //! 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`). +//! `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). //! [`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 //! (`IComponentHandler::performEdit`) are reported by the handle's `idle` so the host can write -//! them into the session. Windows (HWND) and Linux (X11) editor windows are not hosted yet: -//! opening reports "unsupported". +//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports +//! "unsupported". //! //! Not hosted yet: `IMessage`/`IAttributeList` creation through the host (plugins that need it //! for processor ↔ controller messaging may lose those messages), sidechain (aux buses are kept @@ -314,6 +316,9 @@ mod tests { let e = load_bundle(&d.join("z.vst3")).unwrap_err(); assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); } + // Elsewhere there is no library to point at (and no symlink without privileges). + #[cfg(not(unix))] + let _ = lib; let _ = std::fs::remove_dir_all(&d); } } diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index b6fbaa5..6bc732c 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -9,8 +9,8 @@ fn main() { let dir = common::fixture_dir(); let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); - // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported". + if cfg!(any(target_os = "macos", windows)) { assert_eq!(p.editor_size(), Ok((320, 240))); } else { assert!(p.editor_size().is_err()); diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c6..2d2d10c 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -52,7 +52,7 @@ fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) { b.channelCount = 2; copy_c16(name, &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as _; } unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> { @@ -461,7 +461,7 @@ impl IComponentTrait for Synth { b.channelCount = 16; copy_c16("Notes", &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as _; return kResultOk; } kInvalidArgument diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..88fad23 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -212,8 +212,8 @@ fn state_saves_component_and_controller_and_restores_them() { fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap(); - // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported". + if cfg!(any(target_os = "macos", windows)) { assert_eq!(p.editor_size(), Ok((320, 240))); } else { assert!(p.editor_size().is_err()); @@ -222,7 +222,7 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() { // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler. assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]); assert!(ed.idle().is_empty()); - if cfg!(target_os = "macos") { + if cfg!(any(target_os = "macos", windows)) { assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread"); } else { assert!(ed.open().is_err(), "unsupported elsewhere"); From 434c60047df4ac6b26f891448cd9e0e709682810 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:44:36 +0200 Subject: [PATCH 2/5] clap-host: host embedded plugin editors on Windows Plugins that offer no floating editor (most CLAP plugins on Windows) now get a host window of SoundCraft's: the GUI is created embedded, parented to a top-level HWND owned by the app window, sized from get_size, resized on request_resize (applied by the editor's idle on the main thread, then set_size), and torn down when the user closes the window. Floating editors work as before; other platforms still report embedded editors as unsupported. Docs: plugin-parity editor rows and the roadmap log. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/clap-host/Cargo.toml | 4 + crates/clap-host/src/ffi.rs | 324 +++++++++++++++++++++++++++++++++++- crates/clap-host/src/lib.rs | 6 +- docs/plugin-parity.md | 7 +- 6 files changed, 330 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b20fab..c943818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "serde", "soundcraft-dsp", "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..1ca0eb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,8 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change. + - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index e0ff742..e08bbdf 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -13,6 +13,10 @@ serde.workspace = true thiserror.workspace = true log.workspace = true +# 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"] } + [[test]] name = "editor_main_thread" harness = false diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..c83046e 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -18,7 +18,7 @@ use clap_sys::events::{ clap_event_note, clap_event_param_value, clap_input_events, clap_output_events, }; use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports}; -use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui}; +use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui, clap_window, clap_window_handle}; use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency}; use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity}; use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports}; @@ -36,7 +36,7 @@ use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; /// Most plugins one factory may report (hostile-input cap). @@ -275,7 +275,7 @@ impl Bundle { inst.state_ext = inst.extension::(CLAP_EXT_STATE); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { - inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }), + inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false, window: None }), host: Arc::clone(&inst.state), }); Ok(inst) @@ -310,6 +310,8 @@ pub(crate) struct HostState { pub gui_destroyed: AtomicBool, pub gui_show: AtomicBool, pub gui_hide: AtomicBool, + /// A size an embedded editor asked for (`width << 32 | height`; 0 = none pending). + pub gui_resize: AtomicU64, /// Parameter changes the plugin reported (output events: typically edits in its GUI), /// waiting for the UI. Bounded by `EDIT_CAP`; the audio thread only `try_lock`s it. pub edits: Mutex>, @@ -328,6 +330,7 @@ impl Default for HostState { gui_destroyed: AtomicBool::new(false), gui_show: AtomicBool::new(false), gui_hide: AtomicBool::new(false), + gui_resize: AtomicU64::new(0), edits: Mutex::new(Vec::with_capacity(EDIT_CAP)), } } @@ -408,8 +411,16 @@ unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) { unsafe extern "C" fn host_gui_resize_hints_changed(_host: *const clap_host) {} -unsafe extern "C" fn host_gui_request_resize(_host: *const clap_host, _width: u32, _height: u32) -> bool { - // Floating windows belong to the plugin, which resizes them itself. +unsafe extern "C" fn host_gui_request_resize(host: *const clap_host, width: u32, height: u32) -> bool { + // Floating windows belong to the plugin, which resizes them itself; an embedded editor's host + // window is resized by the editor's `idle` on the main thread. + // SAFETY: the plugin passes back the host pointer we gave it. + if let Some(s) = unsafe { state(host) } + && width > 0 + && height > 0 + { + s.gui_resize.store((u64::from(width) << 32) | u64::from(height), Ordering::Relaxed); + } true } @@ -700,6 +711,8 @@ struct GuiInner { alive: bool, created: bool, visible: bool, + /// The host window of an embedded editor (`None` for floating editors). + window: Option, } /// The link between an instance and its editor handles. The instance may process on the audio @@ -715,7 +728,14 @@ pub(crate) struct GuiLink { impl Default for GuiLink { fn default() -> Self { GuiLink { - inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }), + inner: Mutex::new(GuiInner { + plugin: std::ptr::null(), + gui: std::ptr::null(), + alive: false, + created: false, + visible: false, + window: None, + }), host: Arc::new(HostState::default()), } } @@ -755,6 +775,9 @@ impl GuiLink { // SAFETY: live plugin with a created GUI; main thread (checked). unsafe { show(g.plugin) }; } + if let Some(w) = &g.window { + w.raise(); + } g.visible = true; return Ok(()); } @@ -762,7 +785,11 @@ impl GuiLink { let (Some(supported), Some(create)) = (vt.is_api_supported, vt.create) else { return Err("incomplete clap.gui extension".into()) }; // SAFETY: live plugin; `api` is a static NUL-terminated string; main thread. if !unsafe { supported(g.plugin, api.as_ptr(), true) } { - return Err("this plugin offers only an embedded editor; SoundCraft hosts floating CLAP editors".into()); + // SAFETY: as above. + 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()); } self.host.gui_closed.store(false, Ordering::Relaxed); self.host.gui_destroyed.store(false, Ordering::Relaxed); @@ -794,6 +821,61 @@ impl GuiLink { Ok(()) } + /// Embeds the editor in a host window of ours (plugins that offer no floating editor). + fn open_embedded(&self, g: &mut GuiInner, vt: clap_plugin_gui, api: &CStr, title: &str) -> Result<(), String> { + let (Some(create), Some(set_parent)) = (vt.create, vt.set_parent) else { return Err("incomplete clap.gui extension".into()) }; + self.host.gui_closed.store(false, Ordering::Relaxed); + self.host.gui_destroyed.store(false, Ordering::Relaxed); + self.host.gui_resize.store(0, Ordering::Relaxed); + // SAFETY: live plugin; `api` is a static NUL-terminated string; main thread; no GUI yet. + if !unsafe { create(g.plugin, api.as_ptr(), false) } { + 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. + unsafe { f(g.plugin, 1.0) }; + } + let (mut w, mut h) = (640u32, 480u32); + if let Some(f) = vt.get_size { + let (mut a, mut b) = (0u32, 0u32); + // SAFETY: live plugin with a created GUI; both out-pointers are valid locals. + if unsafe { f(g.plugin, &mut a, &mut b) } && a > 0 && b > 0 { + (w, h) = (a.min(16_384), b.min(16_384)); + } + } + let win = match hostwin::HostWindow::new(title, f64::from(w), f64::from(h)) { + Ok(win) => win, + Err(e) => { + GuiLink::destroy(g); + return Err(e); + } + }; + let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } }; + // SAFETY: live plugin with a created embedded GUI; `parent` names our live window and + // outlives the call. + if !unsafe { set_parent(g.plugin, &parent) } { + win.close(); + GuiLink::destroy(g); + return Err("the editor could not attach to its window".into()); + } + g.window = Some(win); + let shown = match vt.show { + // SAFETY: live plugin with a created, parented GUI. + Some(f) => unsafe { f(g.plugin) }, + None => true, + }; + if !shown { + GuiLink::destroy(g); + return Err("the plugin could not show its editor".into()); + } + if let Some(win) = &g.window { + win.raise(); + } + g.visible = true; + Ok(()) + } + fn destroy(g: &mut GuiInner) { if g.created && let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy) @@ -803,6 +885,10 @@ impl GuiLink { } g.created = false; g.visible = false; + // The plugin's child window is gone; now its host window can go too. + if let Some(w) = g.window.take() { + w.close(); + } } /// Closes (destroys) the editor. @@ -815,7 +901,7 @@ impl GuiLink { pub fn is_open(&self) -> bool { let g = self.lock(); - g.alive && g.created && g.visible + g.alive && g.created && g.visible && g.window.as_ref().is_none_or(hostwin::HostWindow::is_visible) } /// Main-thread housekeeping: runs a requested `on_main_thread`, follows the plugin's @@ -836,7 +922,21 @@ impl GuiLink { } } if g.created { - if self.host.gui_closed.swap(false, Ordering::Relaxed) { + let resize = self.host.gui_resize.swap(0, Ordering::Relaxed); + if resize != 0 + && let Some(win) = &g.window + { + let (w, h) = ((resize >> 32) as u32, resize as u32); + hostwin::resize(win.raw(), f64::from(w), f64::from(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) }; + } + } + if g.window.as_ref().is_some_and(|w| !w.is_visible()) { + // The user closed the embedded editor's host window: free the editor. + GuiLink::destroy(&mut g); + } else if self.host.gui_closed.swap(false, Ordering::Relaxed) { // The user closed the floating window (or the plugin lost its GUI): free it. self.host.gui_destroyed.store(false, Ordering::Relaxed); GuiLink::destroy(&mut g); @@ -872,6 +972,8 @@ 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. + g.window = None; } } g.alive = false; @@ -1264,3 +1366,207 @@ impl Drop for Instance { self.created = false; } } + +// ---- host window for embedded editors ------------------------------------------------------ + +/// The Windows host window for embedded editors: a top-level window owned by the app's active +/// window (no taskbar button) whose client area is the plugin's parent `HWND`. Closing it only +/// hides it; the editor link notices in `idle`, destroys the plugin's GUI, then the window. +/// Messages are dispatched by the app's own event loop on the main thread. +#[cfg(windows)] +mod hostwin { + use std::ffi::c_void; + use std::sync::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, + }; + + pub const SUPPORTED: bool = true; + + /// Rust names the process's first thread "main"; the app runs its UI (and message loop) there. + fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect() + } + + const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; + + 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; + } + // SAFETY: default handling of a message for the window it was sent to. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + /// Registers the window class once; the name lives for the whole process. + fn class_name() -> Option<&'static [u16]> { + static CLASS: OnceLock>> = OnceLock::new(); + CLASS + .get_or_init(|| { + let name = wide("SoundCraftClapEditor"); + // SAFETY: null asks for this process's module handle. + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: instance, + hIcon: std::ptr::null_mut(), + // SAFETY: a system cursor (null instance + predefined id). + hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }, + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // SAFETY: a fully initialised class description whose strings outlive the call + // (the name is kept for the process lifetime in this `OnceLock`). + (unsafe { RegisterClassExW(&wc) } != 0).then_some(name) + }) + .as_deref() + } + + /// Outer window size for a client area of `w`×`h`. + fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) { + let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 }; + // SAFETY: `r` is a valid, writable rect. + unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) }; + (r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top)) + } + + /// Owns the window handle; destroyed in `close` (main thread only). + pub struct HostWindow { + hwnd: HWND, + } + + // SAFETY: the handle is only used on the main thread (every method checks); the struct + // merely carries it between threads' locks. + unsafe impl Send for HostWindow {} + + impl HostWindow { + fn live(&self) -> Option { + // SAFETY: `IsWindow` accepts any value and only reports whether it names a window. + (on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd) + } + + pub fn new(title: &str, w: f64, h: f64) -> Result { + if !on_main_thread() { + return Err("plugin editors must be opened on the main thread".into()); + } + let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?; + let title = wide(title); + let (ow, oh) = outer_size(STYLE, 0, w, h); + // SAFETY: main thread; the active window (or null) of this thread's queue. + let owner = unsafe { GetActiveWindow() }; + // SAFETY: registered class and NUL-terminated strings that outlive the call; the + // owner is null or a window of this thread. + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + title.as_ptr(), + STYLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + ow, + oh, + owner, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err("cannot create the editor window".into()); + } + Ok(HostWindow { hwnd }) + } + + /// The window handle (for resizes). + pub fn raw(&self) -> *mut c_void { + self.hwnd + } + + /// The `HWND` the plugin creates its child window in. + pub fn parent(&self) -> *mut c_void { + self.live().unwrap_or(std::ptr::null_mut()) + } + + pub fn raise(&self) { + if let Some(h) = self.live() { + // SAFETY: a live window of this thread. + unsafe { + ShowWindow(h, SW_SHOW); + SetForegroundWindow(h); + } + } + } + + pub fn is_visible(&self) -> bool { + // SAFETY: a live window of this thread. + self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0) + } + + pub fn close(self) { + if let Some(h) = self.live() { + // SAFETY: our own live window; the plugin's GUI was destroyed first. + unsafe { DestroyWindow(h) }; + } + } + } + + /// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only. + pub fn resize(window: *mut c_void, w: f64, h: f64) { + // SAFETY: `IsWindow` accepts any value. + 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. + unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) }; + } +} + +/// Other platforms: embedded editors are not hosted (floating ones still are). +#[cfg(not(windows))] +mod hostwin { + use std::ffi::c_void; + + pub const SUPPORTED: bool = false; + + pub struct HostWindow; + + impl HostWindow { + pub fn new(_title: &str, _w: f64, _h: f64) -> Result { + Err("embedded plugin editors are not hosted on this platform".into()) + } + pub fn raw(&self) -> *mut c_void { + std::ptr::null_mut() + } + pub fn parent(&self) -> *mut c_void { + std::ptr::null_mut() + } + pub fn raise(&self) {} + pub fn is_visible(&self) -> bool { + false + } + pub fn close(self) {} + } + + pub fn resize(_window: *mut c_void, _w: f64, _h: f64) {} +} diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..de64d72 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -20,8 +20,10 @@ //! Plugin state (`clap.state`) round-trips through [`Plugin::save_state`] / //! [`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 embedded -//! editors are not hosted yet, and SoundCraft's generic parameter editor still works for them. +//! 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. //! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes //! and are reported by the editor's `idle`. diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index f59b598..b8d800a 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** 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; no embedded GUI | | -| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing | +| 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: 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,4 +58,5 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows | | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist | From 927eeb58cfb5ef5cef11b68a5c212b6757859424 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:24:26 +0200 Subject: [PATCH 3/5] vst3-host: host plugin editors on Windows Adds a Windows implementation of the editor host window: a top-level HWND owned by the app's active window (no taskbar button), sized from the view's rect, resized on IPlugFrame::resizeView, hidden on close and torn down by the editor link's idle like on macOS. The main thread is the one Rust names "main", where the app runs its message loop. Also fixes two Windows-only build problems that kept the VST3 tests from running there: the fixture's bus flags (the vst3 bindings use i32 enums with MSVC) and an unused variable in a unix-only test. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/vst3-host/Cargo.toml | 4 + crates/vst3-host/src/ffi.rs | 175 ++++++++++++++++++- crates/vst3-host/src/lib.rs | 8 +- crates/vst3-host/tests/editor_main_thread.rs | 4 +- crates/vst3-host/tests/fixture_e2e.rs | 6 +- 6 files changed, 189 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3228925..4b20fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3640,6 +3640,7 @@ dependencies = [ "soundcraft-dsp", "thiserror 2.0.21", "vst3", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 450d454..1c1583a 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -23,6 +23,10 @@ objc2 = "0.6" objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "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). +[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"] } + [[test]] name = "editor_main_thread" harness = false diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 884eb52..cfce193 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1853,8 +1853,181 @@ mod window { } } +/// The Windows host window: a top-level window owned by the app's active window (so it stays +/// above it and has no taskbar button) whose client area is the plugin view's `HWND` parent. +/// Closing it only hides it; the editor link notices in `idle`, detaches the view, then destroys +/// the window. Messages are dispatched by the app's own event loop on the main thread. +#[cfg(windows)] +mod window { + use std::ffi::{c_char, c_void}; + use std::sync::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, + }; + + pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND); + + /// Rust names the process's first thread "main"; the app runs its UI (and message loop) there. + pub fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect() + } + + const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; + + 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; + } + // SAFETY: default handling of a message for the window it was sent to. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + /// Registers the window class once; the name lives for the whole process. + fn class_name() -> Option<&'static [u16]> { + static CLASS: OnceLock>> = OnceLock::new(); + CLASS + .get_or_init(|| { + let name = wide("SoundCraftVst3Editor"); + // SAFETY: null asks for this process's module handle. + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: instance, + hIcon: std::ptr::null_mut(), + // SAFETY: a system cursor (null instance + predefined id). + hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }, + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // SAFETY: a fully initialised class description whose strings outlive the call + // (the name is kept for the process lifetime in this `OnceLock`). + (unsafe { RegisterClassExW(&wc) } != 0).then_some(name) + }) + .as_deref() + } + + /// Outer window size for a client area of `w`×`h`. + fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) { + let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 }; + // SAFETY: `r` is a valid, writable rect. + unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) }; + (r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top)) + } + + /// Owns the window handle; destroyed in `close` (main thread only). + pub struct HostWindow { + hwnd: HWND, + } + + // SAFETY: the handle is only used on the main thread (every method checks); the struct + // merely carries it between threads' locks. + unsafe impl Send for HostWindow {} + + impl HostWindow { + fn live(&self) -> Option { + // SAFETY: `IsWindow` accepts any value and only reports whether it names a window. + (on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd) + } + + pub fn new(title: &str, w: f64, h: f64) -> Result { + if !on_main_thread() { + return Err("not on the main thread".into()); + } + let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?; + let title = wide(title); + let (ow, oh) = outer_size(STYLE, 0, w, h); + // SAFETY: main thread; the active window (or null) of this thread's queue. + let owner = unsafe { GetActiveWindow() }; + // SAFETY: registered class and NUL-terminated strings that outlive the call; the + // owner is null or a window of this thread. + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + title.as_ptr(), + STYLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + ow, + oh, + owner, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err("cannot create the editor window".into()); + } + Ok(HostWindow { hwnd }) + } + + /// The window handle (for the plug frame's resizes). + pub fn raw(&self) -> *mut c_void { + self.hwnd + } + + /// 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()) + } + + pub fn raise(&self) { + if let Some(h) = self.live() { + // SAFETY: a live window of this thread. + unsafe { + ShowWindow(h, SW_SHOW); + SetForegroundWindow(h); + } + } + } + + pub fn is_visible(&self) -> bool { + // SAFETY: a live window of this thread. + self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0) + } + + pub fn close(self) { + if let Some(h) = self.live() { + // SAFETY: our own live window; the view was detached before (see `teardown`). + unsafe { DestroyWindow(h) }; + } + } + } + + /// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only. + pub fn resize(window: *mut c_void, w: f64, h: f64) { + // SAFETY: `IsWindow` accepts any value. + 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. + unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) }; + } +} + /// Other platforms: no editor windows yet (opening reports "unsupported"). -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", windows)))] mod window { use std::ffi::{c_char, c_void}; diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index b872dec..2e3181d 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -25,13 +25,15 @@ //! component state is accepted too) and re-reads the parameter values. //! //! 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`). +//! `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). //! [`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 //! (`IComponentHandler::performEdit`) are reported by the handle's `idle` so the host can write -//! them into the session. Windows (HWND) and Linux (X11) editor windows are not hosted yet: -//! opening reports "unsupported". +//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports +//! "unsupported". //! //! Not hosted yet: `IMessage`/`IAttributeList` creation through the host (plugins that need it //! for processor ↔ controller messaging may lose those messages), sidechain (aux buses are kept diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index b6fbaa5..6bc732c 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -9,8 +9,8 @@ fn main() { let dir = common::fixture_dir(); let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); - // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported". + if cfg!(any(target_os = "macos", windows)) { assert_eq!(p.editor_size(), Ok((320, 240))); } else { assert!(p.editor_size().is_err()); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index b36e9bb..317fdb1 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -237,8 +237,8 @@ fn host_set_controller_param_survives_state_round_trip() { fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap(); - // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported". + if cfg!(any(target_os = "macos", windows)) { assert_eq!(p.editor_size(), Ok((320, 240))); } else { assert!(p.editor_size().is_err()); @@ -247,7 +247,7 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() { // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler. assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]); assert!(ed.idle().is_empty()); - if cfg!(target_os = "macos") { + if cfg!(any(target_os = "macos", windows)) { assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread"); } else { assert!(ed.open().is_err(), "unsupported elsewhere"); From 7eb643fc5eb11a3bced8bc90f12a6713999fceb4 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:44:36 +0200 Subject: [PATCH 4/5] clap-host: host embedded plugin editors on Windows Plugins that offer no floating editor (most CLAP plugins on Windows) now get a host window of SoundCraft's: the GUI is created embedded, parented to a top-level HWND owned by the app window, sized from get_size, resized on request_resize (applied by the editor's idle on the main thread, then set_size), and torn down when the user closes the window. Floating editors work as before; other platforms still report embedded editors as unsupported. Docs: plugin-parity editor rows and the roadmap log. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/clap-host/Cargo.toml | 4 + crates/clap-host/src/ffi.rs | 324 +++++++++++++++++++++++++++++++++++- crates/clap-host/src/lib.rs | 6 +- docs/plugin-parity.md | 7 +- 6 files changed, 330 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b20fab..c943818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "serde", "soundcraft-dsp", "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index bab0d92..2069627 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,8 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256) - **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248) - **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249) +- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change. + - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index e0ff742..e08bbdf 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -13,6 +13,10 @@ serde.workspace = true thiserror.workspace = true log.workspace = true +# 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"] } + [[test]] name = "editor_main_thread" harness = false diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..c83046e 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -18,7 +18,7 @@ use clap_sys::events::{ clap_event_note, clap_event_param_value, clap_input_events, clap_output_events, }; use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports}; -use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui}; +use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui, clap_window, clap_window_handle}; use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency}; use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity}; use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports}; @@ -36,7 +36,7 @@ use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; /// Most plugins one factory may report (hostile-input cap). @@ -275,7 +275,7 @@ impl Bundle { inst.state_ext = inst.extension::(CLAP_EXT_STATE); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { - inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }), + inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false, window: None }), host: Arc::clone(&inst.state), }); Ok(inst) @@ -310,6 +310,8 @@ pub(crate) struct HostState { pub gui_destroyed: AtomicBool, pub gui_show: AtomicBool, pub gui_hide: AtomicBool, + /// A size an embedded editor asked for (`width << 32 | height`; 0 = none pending). + pub gui_resize: AtomicU64, /// Parameter changes the plugin reported (output events: typically edits in its GUI), /// waiting for the UI. Bounded by `EDIT_CAP`; the audio thread only `try_lock`s it. pub edits: Mutex>, @@ -328,6 +330,7 @@ impl Default for HostState { gui_destroyed: AtomicBool::new(false), gui_show: AtomicBool::new(false), gui_hide: AtomicBool::new(false), + gui_resize: AtomicU64::new(0), edits: Mutex::new(Vec::with_capacity(EDIT_CAP)), } } @@ -408,8 +411,16 @@ unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) { unsafe extern "C" fn host_gui_resize_hints_changed(_host: *const clap_host) {} -unsafe extern "C" fn host_gui_request_resize(_host: *const clap_host, _width: u32, _height: u32) -> bool { - // Floating windows belong to the plugin, which resizes them itself. +unsafe extern "C" fn host_gui_request_resize(host: *const clap_host, width: u32, height: u32) -> bool { + // Floating windows belong to the plugin, which resizes them itself; an embedded editor's host + // window is resized by the editor's `idle` on the main thread. + // SAFETY: the plugin passes back the host pointer we gave it. + if let Some(s) = unsafe { state(host) } + && width > 0 + && height > 0 + { + s.gui_resize.store((u64::from(width) << 32) | u64::from(height), Ordering::Relaxed); + } true } @@ -700,6 +711,8 @@ struct GuiInner { alive: bool, created: bool, visible: bool, + /// The host window of an embedded editor (`None` for floating editors). + window: Option, } /// The link between an instance and its editor handles. The instance may process on the audio @@ -715,7 +728,14 @@ pub(crate) struct GuiLink { impl Default for GuiLink { fn default() -> Self { GuiLink { - inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }), + inner: Mutex::new(GuiInner { + plugin: std::ptr::null(), + gui: std::ptr::null(), + alive: false, + created: false, + visible: false, + window: None, + }), host: Arc::new(HostState::default()), } } @@ -755,6 +775,9 @@ impl GuiLink { // SAFETY: live plugin with a created GUI; main thread (checked). unsafe { show(g.plugin) }; } + if let Some(w) = &g.window { + w.raise(); + } g.visible = true; return Ok(()); } @@ -762,7 +785,11 @@ impl GuiLink { let (Some(supported), Some(create)) = (vt.is_api_supported, vt.create) else { return Err("incomplete clap.gui extension".into()) }; // SAFETY: live plugin; `api` is a static NUL-terminated string; main thread. if !unsafe { supported(g.plugin, api.as_ptr(), true) } { - return Err("this plugin offers only an embedded editor; SoundCraft hosts floating CLAP editors".into()); + // SAFETY: as above. + 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()); } self.host.gui_closed.store(false, Ordering::Relaxed); self.host.gui_destroyed.store(false, Ordering::Relaxed); @@ -794,6 +821,61 @@ impl GuiLink { Ok(()) } + /// Embeds the editor in a host window of ours (plugins that offer no floating editor). + fn open_embedded(&self, g: &mut GuiInner, vt: clap_plugin_gui, api: &CStr, title: &str) -> Result<(), String> { + let (Some(create), Some(set_parent)) = (vt.create, vt.set_parent) else { return Err("incomplete clap.gui extension".into()) }; + self.host.gui_closed.store(false, Ordering::Relaxed); + self.host.gui_destroyed.store(false, Ordering::Relaxed); + self.host.gui_resize.store(0, Ordering::Relaxed); + // SAFETY: live plugin; `api` is a static NUL-terminated string; main thread; no GUI yet. + if !unsafe { create(g.plugin, api.as_ptr(), false) } { + 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. + unsafe { f(g.plugin, 1.0) }; + } + let (mut w, mut h) = (640u32, 480u32); + if let Some(f) = vt.get_size { + let (mut a, mut b) = (0u32, 0u32); + // SAFETY: live plugin with a created GUI; both out-pointers are valid locals. + if unsafe { f(g.plugin, &mut a, &mut b) } && a > 0 && b > 0 { + (w, h) = (a.min(16_384), b.min(16_384)); + } + } + let win = match hostwin::HostWindow::new(title, f64::from(w), f64::from(h)) { + Ok(win) => win, + Err(e) => { + GuiLink::destroy(g); + return Err(e); + } + }; + let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } }; + // SAFETY: live plugin with a created embedded GUI; `parent` names our live window and + // outlives the call. + if !unsafe { set_parent(g.plugin, &parent) } { + win.close(); + GuiLink::destroy(g); + return Err("the editor could not attach to its window".into()); + } + g.window = Some(win); + let shown = match vt.show { + // SAFETY: live plugin with a created, parented GUI. + Some(f) => unsafe { f(g.plugin) }, + None => true, + }; + if !shown { + GuiLink::destroy(g); + return Err("the plugin could not show its editor".into()); + } + if let Some(win) = &g.window { + win.raise(); + } + g.visible = true; + Ok(()) + } + fn destroy(g: &mut GuiInner) { if g.created && let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy) @@ -803,6 +885,10 @@ impl GuiLink { } g.created = false; g.visible = false; + // The plugin's child window is gone; now its host window can go too. + if let Some(w) = g.window.take() { + w.close(); + } } /// Closes (destroys) the editor. @@ -815,7 +901,7 @@ impl GuiLink { pub fn is_open(&self) -> bool { let g = self.lock(); - g.alive && g.created && g.visible + g.alive && g.created && g.visible && g.window.as_ref().is_none_or(hostwin::HostWindow::is_visible) } /// Main-thread housekeeping: runs a requested `on_main_thread`, follows the plugin's @@ -836,7 +922,21 @@ impl GuiLink { } } if g.created { - if self.host.gui_closed.swap(false, Ordering::Relaxed) { + let resize = self.host.gui_resize.swap(0, Ordering::Relaxed); + if resize != 0 + && let Some(win) = &g.window + { + let (w, h) = ((resize >> 32) as u32, resize as u32); + hostwin::resize(win.raw(), f64::from(w), f64::from(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) }; + } + } + if g.window.as_ref().is_some_and(|w| !w.is_visible()) { + // The user closed the embedded editor's host window: free the editor. + GuiLink::destroy(&mut g); + } else if self.host.gui_closed.swap(false, Ordering::Relaxed) { // The user closed the floating window (or the plugin lost its GUI): free it. self.host.gui_destroyed.store(false, Ordering::Relaxed); GuiLink::destroy(&mut g); @@ -872,6 +972,8 @@ 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. + g.window = None; } } g.alive = false; @@ -1264,3 +1366,207 @@ impl Drop for Instance { self.created = false; } } + +// ---- host window for embedded editors ------------------------------------------------------ + +/// The Windows host window for embedded editors: a top-level window owned by the app's active +/// window (no taskbar button) whose client area is the plugin's parent `HWND`. Closing it only +/// hides it; the editor link notices in `idle`, destroys the plugin's GUI, then the window. +/// Messages are dispatched by the app's own event loop on the main thread. +#[cfg(windows)] +mod hostwin { + use std::ffi::c_void; + use std::sync::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, + }; + + pub const SUPPORTED: bool = true; + + /// Rust names the process's first thread "main"; the app runs its UI (and message loop) there. + fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect() + } + + const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; + + 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; + } + // SAFETY: default handling of a message for the window it was sent to. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + /// Registers the window class once; the name lives for the whole process. + fn class_name() -> Option<&'static [u16]> { + static CLASS: OnceLock>> = OnceLock::new(); + CLASS + .get_or_init(|| { + let name = wide("SoundCraftClapEditor"); + // SAFETY: null asks for this process's module handle. + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: instance, + hIcon: std::ptr::null_mut(), + // SAFETY: a system cursor (null instance + predefined id). + hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }, + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // SAFETY: a fully initialised class description whose strings outlive the call + // (the name is kept for the process lifetime in this `OnceLock`). + (unsafe { RegisterClassExW(&wc) } != 0).then_some(name) + }) + .as_deref() + } + + /// Outer window size for a client area of `w`×`h`. + fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) { + let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 }; + // SAFETY: `r` is a valid, writable rect. + unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) }; + (r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top)) + } + + /// Owns the window handle; destroyed in `close` (main thread only). + pub struct HostWindow { + hwnd: HWND, + } + + // SAFETY: the handle is only used on the main thread (every method checks); the struct + // merely carries it between threads' locks. + unsafe impl Send for HostWindow {} + + impl HostWindow { + fn live(&self) -> Option { + // SAFETY: `IsWindow` accepts any value and only reports whether it names a window. + (on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd) + } + + pub fn new(title: &str, w: f64, h: f64) -> Result { + if !on_main_thread() { + return Err("plugin editors must be opened on the main thread".into()); + } + let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?; + let title = wide(title); + let (ow, oh) = outer_size(STYLE, 0, w, h); + // SAFETY: main thread; the active window (or null) of this thread's queue. + let owner = unsafe { GetActiveWindow() }; + // SAFETY: registered class and NUL-terminated strings that outlive the call; the + // owner is null or a window of this thread. + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + title.as_ptr(), + STYLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + ow, + oh, + owner, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err("cannot create the editor window".into()); + } + Ok(HostWindow { hwnd }) + } + + /// The window handle (for resizes). + pub fn raw(&self) -> *mut c_void { + self.hwnd + } + + /// The `HWND` the plugin creates its child window in. + pub fn parent(&self) -> *mut c_void { + self.live().unwrap_or(std::ptr::null_mut()) + } + + pub fn raise(&self) { + if let Some(h) = self.live() { + // SAFETY: a live window of this thread. + unsafe { + ShowWindow(h, SW_SHOW); + SetForegroundWindow(h); + } + } + } + + pub fn is_visible(&self) -> bool { + // SAFETY: a live window of this thread. + self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0) + } + + pub fn close(self) { + if let Some(h) = self.live() { + // SAFETY: our own live window; the plugin's GUI was destroyed first. + unsafe { DestroyWindow(h) }; + } + } + } + + /// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only. + pub fn resize(window: *mut c_void, w: f64, h: f64) { + // SAFETY: `IsWindow` accepts any value. + 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. + unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) }; + } +} + +/// Other platforms: embedded editors are not hosted (floating ones still are). +#[cfg(not(windows))] +mod hostwin { + use std::ffi::c_void; + + pub const SUPPORTED: bool = false; + + pub struct HostWindow; + + impl HostWindow { + pub fn new(_title: &str, _w: f64, _h: f64) -> Result { + Err("embedded plugin editors are not hosted on this platform".into()) + } + pub fn raw(&self) -> *mut c_void { + std::ptr::null_mut() + } + pub fn parent(&self) -> *mut c_void { + std::ptr::null_mut() + } + pub fn raise(&self) {} + pub fn is_visible(&self) -> bool { + false + } + pub fn close(self) {} + } + + pub fn resize(_window: *mut c_void, _w: f64, _h: f64) {} +} diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..de64d72 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -20,8 +20,10 @@ //! Plugin state (`clap.state`) round-trips through [`Plugin::save_state`] / //! [`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 embedded -//! editors are not hosted yet, and SoundCraft's generic parameter editor still works for them. +//! 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. //! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes //! and are reported by the editor's `idle`. diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index f59b598..b8d800a 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** 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; no embedded GUI | | -| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing | +| 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: 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,4 +58,5 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows | | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist | From fbee545a5e4a6df20de46a303ca8a531c03145fe Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Tue, 13 Oct 2026 11:44:51 +0200 Subject: [PATCH 5/5] ROADMAP: no blank line inside the progress log (rebase leftover) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2069627..e85d53a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,6 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248) - **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249) - **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change. - - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/