diff --git a/CHANGELOG.md b/CHANGELOG.md index de0bee0f3..4462c8746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ Since there is no stable release yet, the changes are organized per day in reverse chronological order. The main purpose of this document in its current state is to list breaking changes. +## [2026-06-11] + +### Added + +- `Editor` has a new `set_size()` method with a default implementation that + rejects the resize. CLAP hosts that resize plugin windows host-side (the + user drags the host's window frame and the host proposes sizes through + `adjust_size()`/`set_size()`, like FL Studio does) now have those + proposals forwarded to the editor. The CLAP wrapper also declares + `can_resize` so hosts act on plugin-initiated `request_resize()` calls. +- `nih_plug_vizia` has a new `ViziaState::new_resizable()` constructor that + accepts host-driven resizes: a callback updates (and may clamp) whatever + state the size function reads, and the new size is applied to the embedded + window on the GUI thread through vizia's event proxy. + ## [2025-02-23] ### Breaking changes diff --git a/nih_plug_vizia/src/editor.rs b/nih_plug_vizia/src/editor.rs index a285d8682..ee1fb524d 100644 --- a/nih_plug_vizia/src/editor.rs +++ b/nih_plug_vizia/src/editor.rs @@ -30,6 +30,13 @@ pub(crate) struct ViziaEditor { /// to compute a property in an event handler. Like when positioning an element based on the /// display value's width. pub(crate) emit_parameters_changed_event: Arc, + + /// A proxy into the running GUI's event loop, captured + /// when the editor spawns. `Editor::set_size` (called from the host's + /// thread) uses it to wake the GUI and apply a host-driven resize — the + /// proxy queue is drained every frame, unlike the idle callback, which + /// vizia_baseview only runs on input events. + pub(crate) host_resize_proxy: Arc>>, } impl Editor for ViziaEditor { @@ -41,6 +48,7 @@ impl Editor for ViziaEditor { let app = self.app.clone(); let vizia_state = self.vizia_state.clone(); let theming = self.theming; + let host_resize_proxy = self.host_resize_proxy.clone(); let (unscaled_width, unscaled_height) = vizia_state.inner_logical_size(); let system_scaling_factor = self.scaling_factor.load(); @@ -79,6 +87,12 @@ impl Editor for ViziaEditor { } .build(cx); + // Capture a proxy into this GUI's event loop so + // host-driven resizes (arriving on the host's thread) can wake it. + if let Ok(mut proxy) = host_resize_proxy.lock() { + *proxy = Some(cx.get_proxy()); + } + app(cx, context.clone()) }) .with_scale_policy( @@ -94,6 +108,7 @@ impl Editor for ViziaEditor { }) .on_idle({ let emit_parameters_changed_event = self.emit_parameters_changed_event.clone(); + let vizia_state = self.vizia_state.clone(); move |cx| { if emit_parameters_changed_event .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed) @@ -104,6 +119,27 @@ impl Editor for ViziaEditor { .propagate(Propagation::Subtree), ); } + + // A host-driven resize updated the size + // state from the host's thread; apply it to the embedded + // window here on the GUI thread (one-shot, so a host that + // rejects the follow-up request_resize can't ping-pong). + if vizia_state + .deferred_resize + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + let (width, height) = vizia_state.inner_logical_size(); + // The host initiated this resize — skip the + // `request_resize` renegotiation that the following + // `GeometryChanged` would trigger (hosts that refuse + // plugin-initiated requests would revert it). + vizia_state + .suppress_resize_request + .store(true, Ordering::Release); + let mut event_cx = EventContext::new(cx); + event_cx.set_window_size(WindowSize { width, height }); + } } }); @@ -140,6 +176,31 @@ impl Editor for ViziaEditor { true } + fn set_size(&self, width: u32, height: u32) -> bool { + // Host-driven resize. `width`/`height` arrive in the + // same units `size()` reports (logical pixels including the user + // scale factor). Convert to `size_fn` units, let the app's callback + // update (and clamp) its size state, then wake the GUI's event loop + // to apply the result (`ApplyHostResize` → `WindowModel`). The idle + // flag stays as a fallback for runtimes without an event proxy. + let Some(on_host_resize) = &self.vizia_state.on_host_resize else { + return false; + }; + let user_scale = self.vizia_state.user_scale_factor(); + let logical_width = (f64::from(width) / user_scale).round() as u32; + let logical_height = (f64::from(height) / user_scale).round() as u32; + on_host_resize(logical_width, logical_height); + self.vizia_state + .deferred_resize + .store(true, Ordering::Release); + if let Ok(mut proxy) = self.host_resize_proxy.lock() { + if let Some(proxy) = proxy.as_mut() { + let _ = proxy.emit(crate::widgets::ApplyHostResize); + } + } + true + } + fn param_value_changed(&self, _id: &str, _normalized_value: f32) { // This will cause a future idle callback to send a parameters changed event. // NOTE: We could add an event containing the parameter's ID and the normalized value, but diff --git a/nih_plug_vizia/src/lib.rs b/nih_plug_vizia/src/lib.rs index aafefa441..1b195b230 100644 --- a/nih_plug_vizia/src/lib.rs +++ b/nih_plug_vizia/src/lib.rs @@ -63,6 +63,7 @@ where scaling_factor: AtomicCell::new(Some(1.0)), emit_parameters_changed_event: Arc::new(AtomicBool::new(false)), + host_resize_proxy: Arc::new(std::sync::Mutex::new(None)), })) } @@ -98,6 +99,23 @@ pub struct ViziaState { /// Whether the editor's window is currently open. #[serde(skip)] open: AtomicBool, + /// Called (from the host's thread) when the host wants + /// to resize the window, with the proposed size in `size_fn` units. The + /// implementation should update whatever state `size_fn` reads (clamping + /// as desired); the actual window resize is applied deferred on the GUI + /// thread. `None` = host-driven resizing rejected (upstream behavior). + #[serde(skip)] + on_host_resize: Option>, + /// One-shot flag telling the GUI thread's idle callback + /// to re-apply `size_fn` to the window after a host-driven resize. + #[serde(skip)] + deferred_resize: AtomicBool, + /// One-shot flag suppressing the `request_resize` + /// renegotiation for the next `GeometryChanged` — that resize was + /// initiated by the host itself, and hosts that refuse plugin-initiated + /// requests (FL Studio) would otherwise revert it immediately. + #[serde(skip)] + pub(crate) suppress_resize_request: AtomicBool, } /// A default implementation for `size_fn` needed to be able to derive the `Deserialize` trait. @@ -140,6 +158,29 @@ impl ViziaState { size_fn: Box::new(size_fn), scale_factor: AtomicCell::new(1.0), open: AtomicBool::new(false), + on_host_resize: None, + deferred_resize: AtomicBool::new(false), + suppress_resize_request: AtomicBool::new(false), + }) + } + + /// Like [`new()`][Self::new()], but additionally accepts + /// **host-driven** window resizes (e.g. the user dragging the plugin + /// frame in FL Studio). `on_host_resize` receives the proposed size in + /// the same logical units `size_fn` returns and must update whatever + /// state `size_fn` reads (clamping as desired); the window is then + /// resized to `size_fn`'s new value on the GUI thread. + pub fn new_resizable( + size_fn: impl Fn() -> (u32, u32) + Send + Sync + 'static, + on_host_resize: impl Fn(u32, u32) + Send + Sync + 'static, + ) -> Arc { + Arc::new(ViziaState { + size_fn: Box::new(size_fn), + scale_factor: AtomicCell::new(1.0), + open: AtomicBool::new(false), + on_host_resize: Some(Box::new(on_host_resize)), + deferred_resize: AtomicBool::new(false), + suppress_resize_request: AtomicBool::new(false), }) } @@ -154,6 +195,9 @@ impl ViziaState { size_fn: Box::new(size_fn), scale_factor: AtomicCell::new(default_scale_factor), open: AtomicBool::new(false), + on_host_resize: None, + deferred_resize: AtomicBool::new(false), + suppress_resize_request: AtomicBool::new(false), }) } diff --git a/nih_plug_vizia/src/widgets.rs b/nih_plug_vizia/src/widgets.rs index 01da50098..c8cd00f46 100644 --- a/nih_plug_vizia/src/widgets.rs +++ b/nih_plug_vizia/src/widgets.rs @@ -144,8 +144,30 @@ impl Model for ParamModel { } } +/// Emitted through vizia's event proxy when the host +/// resized the window (`Editor::set_size`). Handled by [`WindowModel`] on the +/// GUI thread, where the new size (already stored in whatever the +/// `ViziaState`'s size function reads) is applied to the embedded window. +/// An event (rather than an idle-callback flag) because vizia_baseview only +/// runs the idle callback on input events, while the proxy queue is drained +/// every frame — a host frame drag generates no input events. +pub(crate) struct ApplyHostResize; + impl Model for WindowModel { fn event(&mut self, cx: &mut EventContext, event: &mut Event) { + event.map(|_: &ApplyHostResize, meta| { + let (width, height) = self.vizia_state.inner_logical_size(); + // The host initiated this resize: skip the request_resize + // renegotiation that the resulting GeometryChanged would trigger + // (hosts that refuse plugin-initiated requests would revert + // their own resize). + self.vizia_state + .suppress_resize_request + .store(true, std::sync::atomic::Ordering::Release); + cx.set_window_size(WindowSize { width, height }); + meta.consume(); + }); + event.map(|gui_context_event, meta| match gui_context_event { GuiContextEvent::Resize => { // This will trigger a `WindowEvent::GeometryChanged`, which in turn causes the @@ -180,6 +202,22 @@ impl Model for WindowModel { return; } + // When this geometry change was caused by a + // **host-driven** resize (`Editor::set_size`), the host + // already knows and approved the size — renegotiating via + // `request_resize` would make hosts that refuse + // plugin-initiated requests (FL Studio) revert their own + // resize. Record the new size and stop here. + if self + .vizia_state + .suppress_resize_request + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + self.last_inner_window_size.store(logical_size); + self.vizia_state.scale_factor.store(scale_factor); + return; + } + // Our embedded baseview window will have already been resized. If the host does not // accept our new size, then we'll try to undo that self.last_inner_window_size.store(logical_size); diff --git a/src/editor.rs b/src/editor.rs index c6bd190e0..94413f05e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -54,6 +54,20 @@ pub trait Editor: Send { /// there. fn set_scale_factor(&self, factor: f32) -> bool; + /// Called when the **host** wants to resize the editor + /// (host-driven resizing, e.g. the user dragging the plugin window frame + /// in FL Studio). `width` and `height` use the same unit as + /// [`size()`][Self::size()] — logical pixels before HiDPI scaling. + /// + /// Return `true` if the editor accepts the resize (possibly after + /// clamping): it must then report the new size from `size()` and resize + /// its embedded window on its own GUI thread. The default rejects host + /// resizing, which keeps the previous fixed-size behavior. + fn set_size(&self, width: u32, height: u32) -> bool { + let _ = (width, height); + false + } + /// Called whenever a specific parameter's value has changed while the editor is open. You don't /// need to do anything with this, but this can be used to force a redraw when the host sends a /// new value for a parameter or when a parameter change sent to the host gets processed. diff --git a/src/wrapper/clap/wrapper.rs b/src/wrapper/clap/wrapper.rs index 3b6a63edb..f8d906460 100644 --- a/src/wrapper/clap/wrapper.rs +++ b/src/wrapper/clap/wrapper.rs @@ -797,13 +797,11 @@ impl Wrapper

{ (Some(host_gui), Some(editor)) => { let (unscaled_width, unscaled_height) = editor.lock().size(); let scaling_factor = self.editor_scaling_factor.load(Ordering::Relaxed); + let width = (unscaled_width as f32 * scaling_factor).round() as u32; + let height = (unscaled_height as f32 * scaling_factor).round() as u32; unsafe_clap_call! { - host_gui=>request_resize( - &*self.host_callback, - (unscaled_width as f32 * scaling_factor).round() as u32, - (unscaled_height as f32 * scaling_factor).round() as u32, - ) + host_gui=>request_resize(&*self.host_callback, width, height) } } _ => false, @@ -2712,8 +2710,14 @@ impl Wrapper

{ } unsafe extern "C" fn ext_gui_can_resize(_plugin: *const clap_plugin) -> bool { - // TODO: Implement Host->Plugin GUI resizing - false + // Declare resizability so hosts act on the plugin's + // own `request_resize()` calls (FL Studio checks `can_resize` and + // otherwise ignores the live request, only picking the new size up + // on the next window open). Host->plugin resizing is still + // effectively rejected: `adjust_size`/`set_size` only ever accept + // the editor's current size, so a host-initiated frame drag snaps + // back instead of clipping the GUI. + true } unsafe extern "C" fn ext_gui_get_resize_hints( @@ -2725,12 +2729,20 @@ impl Wrapper

{ } unsafe extern "C" fn ext_gui_adjust_size( - _plugin: *const clap_plugin, - _width: *mut u32, - _height: *mut u32, + plugin: *const clap_plugin, + width: *mut u32, + height: *mut u32, ) -> bool { - // TODO: Implement Host->Plugin GUI resizing - false + // Host-driven resizing is + // accepted, so the proposal is echoed back unchanged here and applied + // (with the editor's own clamping) in `set_size`. Editors that don't + // implement `Editor::set_size` still only support their current size. + check_null_ptr!(false, plugin, (*plugin).plugin_data, width, height); + let wrapper = &*((*plugin).plugin_data as *const Self); + if wrapper.editor.borrow().is_none() { + return false; + } + true } unsafe extern "C" fn ext_gui_set_size( @@ -2738,19 +2750,31 @@ impl Wrapper

{ width: u32, height: u32, ) -> bool { - // TODO: Implement Host->Plugin GUI resizing - // TODO: The host will also call this if an asynchronous (on Linux) resize request fails + // Forward host-driven resizes to the editor + // (`Editor::set_size`, logical pixels). Editors without support fall + // back to the old behavior of only accepting their current size. check_null_ptr!(false, plugin, (*plugin).plugin_data); let wrapper = &*((*plugin).plugin_data as *const Self); - let (unscaled_width, unscaled_height) = - wrapper.editor.borrow().as_ref().unwrap().lock().size(); let scaling_factor = wrapper.editor_scaling_factor.load(Ordering::Relaxed); + let logical_width = (width as f32 / scaling_factor).round() as u32; + let logical_height = (height as f32 / scaling_factor).round() as u32; + + let editor = wrapper.editor.borrow(); + let Some(editor) = editor.as_ref() else { + return false; + }; + let editor = editor.lock(); + if editor.set_size(logical_width, logical_height) { + return true; + } + + // Legacy fixed-size editors: only the current size is acceptable. + let (unscaled_width, unscaled_height) = editor.size(); let (editor_width, editor_height) = ( (unscaled_width as f32 * scaling_factor).round() as u32, (unscaled_height as f32 * scaling_factor).round() as u32, ); - width == editor_width && height == editor_height }