Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions nih_plug_vizia/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AtomicBool>,

/// 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<std::sync::Mutex<Option<vizia::context::ContextProxy>>>,
}

impl Editor for ViziaEditor {
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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 });
}
}
});

Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions nih_plug_vizia/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}))
}

Expand Down Expand Up @@ -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<Box<dyn Fn(u32, u32) + Send + Sync>>,
/// 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.
Expand Down Expand Up @@ -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<ViziaState> {
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),
})
}

Expand All @@ -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),
})
}

Expand Down
38 changes: 38 additions & 0 deletions nih_plug_vizia/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 41 additions & 17 deletions src/wrapper/clap/wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,13 +797,11 @@ impl<P: ClapPlugin> Wrapper<P> {
(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,
Expand Down Expand Up @@ -2712,8 +2710,14 @@ impl<P: ClapPlugin> Wrapper<P> {
}

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(
Expand All @@ -2725,32 +2729,52 @@ impl<P: ClapPlugin> Wrapper<P> {
}

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(
plugin: *const clap_plugin,
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
}

Expand Down