mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
8d5b38ab3a
commit
927eeb58cf
6 files changed
+189
-9
No files matched your search
Generated
+1
@@ -3640,6 +3640,7 @@ dependencies = [
|
||||
"soundcraft-dsp",
|
||||
"thiserror 2.0.21",
|
||||
"vst3",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
+174
-1
@@ -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<u16> {
|
||||
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<Option<Vec<u16>>> = 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::<WNDCLASSEXW>() 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<HWND> {
|
||||
// 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<HostWindow, String> {
|
||||
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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user