truce_core/editor.rs
1use std::ops::Deref;
2use std::sync::Arc;
3
4use truce_params::Params;
5use truce_params::sample::Float;
6
7use crate::events::TransportInfo;
8
9/// A raw pointer wrapper that is `Send + Sync`.
10///
11/// Used to capture `*const Params` / host-handle pointers in
12/// `PluginContext` closures without the `ptr as usize` hack. The
13/// `Send`/`Sync` impls are unconditional in `T` - they have to be,
14/// because the wrapped types are typically `#[repr(C)]` host structs
15/// that are themselves `!Send + !Sync` by default. Construction is
16/// therefore `unsafe`: each call site must justify why cross-thread
17/// access to the pointed-to data is sound.
18///
19/// Justifications used in-tree:
20/// - **`P: Params`** - fields are atomic; concurrent reads from the
21/// GUI thread while the audio thread writes are safe by design.
22/// - **Format-host handles** (`clap_host`, `AEffect`, etc.) - used
23/// only from a single thread (UI), and the wrapping is purely for
24/// capturing in `Send + Sync` closures stored in `PluginContext`.
25///
26/// The pointed-to data must outlive the `SendPtr`. In the plugin
27/// context, the plugin instance (which owns the params) always
28/// outlives the editor.
29pub struct SendPtr<T>(*const T);
30
31impl<T> SendPtr<T> {
32 /// Wrap a raw pointer.
33 ///
34 /// # Safety
35 /// The caller must ensure that:
36 /// 1. The pointed-to data outlives every clone of this `SendPtr`.
37 /// 2. Cross-thread access to `*ptr` is sound - either because `T`
38 /// is `Sync`, because access is synchronized externally
39 /// (atomic fields, Mutex, single-thread-only access pattern),
40 /// or because the wrapper is only ever read on a thread where
41 /// `T: Sync` would hold.
42 pub unsafe fn new(ptr: *const T) -> Self {
43 Self(ptr)
44 }
45
46 /// Dereference the pointer.
47 ///
48 /// # Safety
49 /// The pointed-to data must still be alive.
50 #[must_use]
51 pub unsafe fn get(&self) -> &T {
52 unsafe { &*self.0 }
53 }
54
55 /// Get the raw pointer.
56 #[must_use]
57 pub fn as_ptr(&self) -> *const T {
58 self.0
59 }
60}
61
62impl<T> Clone for SendPtr<T> {
63 fn clone(&self) -> Self {
64 *self
65 }
66}
67
68impl<T> Copy for SendPtr<T> {}
69
70// SAFETY: justified at each `unsafe SendPtr::new(...)` call site.
71unsafe impl<T> Send for SendPtr<T> {}
72unsafe impl<T> Sync for SendPtr<T> {}
73
74/// Raw platform window handle for GUI parenting.
75#[derive(Clone, Copy, Debug)]
76pub enum RawWindowHandle {
77 AppKit(*mut std::ffi::c_void), // macOS NSView*
78 UiKit(*mut std::ffi::c_void), // iOS / iPadOS UIView*
79 Win32(*mut std::ffi::c_void), // HWND
80 X11(u64), // X11 Window ID
81}
82
83/// Plugin GUI editor.
84pub trait Editor: Send {
85 /// Initial window size in logical points.
86 ///
87 /// On a 2x Retina display, `(400, 300)` produces an 800x600 pixel window.
88 /// On a 1x display, it produces a 400x300 pixel window.
89 fn size(&self) -> (u32, u32);
90
91 /// Create the GUI as a child of the host-provided parent window.
92 fn open(&mut self, parent: RawWindowHandle, context: PluginContext);
93
94 /// Destroy the GUI.
95 fn close(&mut self);
96
97 /// Called ~60fps on the host's UI thread for repaint/animation.
98 fn idle(&mut self) {}
99
100 /// Host requests a resize. Return true to accept.
101 fn set_size(&mut self, _width: u32, _height: u32) -> bool {
102 false
103 }
104
105 /// Whether the plugin supports resizing.
106 fn can_resize(&self) -> bool {
107 false
108 }
109
110 /// Whether the editor permits the standalone window to be
111 /// maximized (the WM maximize button / double-click-titlebar
112 /// maximize / macOS zoom-and-fullscreen / Windows maximize box).
113 ///
114 /// Standalone-only: in CLAP / VST3 / AU the host owns the window
115 /// frame, so this is ignored there (same as `size_increment`'s
116 /// WM-snap note). Subordinate to [`Self::can_resize`] - a
117 /// non-resizable editor can never be maximized regardless of this
118 /// value, since the standalone pins min == max, which already
119 /// blocks it.
120 ///
121 /// Defaults to `false`: the standalone host removes the maximize
122 /// affordance from resizable editors, so the window stays within
123 /// the edge-drag bounds the WM already enforces and can't jump past
124 /// the editor's [`Self::max_size`] into an unpainted margin around
125 /// the clamped surface. Override to `true` for editors that render
126 /// correctly at arbitrary size (typically an unbounded `max_size`)
127 /// and want the maximize affordance.
128 fn can_maximize(&self) -> bool {
129 false
130 }
131
132 /// Minimum size the editor can render at, in logical points.
133 /// Defaults to `(1, 1)`. Wrappers consult this for CLAP's
134 /// `gui_get_resize_hints` and VST3's `checkSizeConstraint`.
135 /// Ignored when `can_resize()` returns `false`.
136 fn min_size(&self) -> (u32, u32) {
137 (1, 1)
138 }
139
140 /// Maximum size the editor can render at, in logical points.
141 /// Defaults to `(u32::MAX, u32::MAX)`. Same wrapper consumers
142 /// as `min_size`.
143 fn max_size(&self) -> (u32, u32) {
144 (u32::MAX, u32::MAX)
145 }
146
147 /// Logical-point granularity for interactive resize, or `None`
148 /// for free (pixel-precise) resizing. The standalone X11 host
149 /// maps this onto WM resize increments (`PResizeInc`) so the
150 /// window manager snaps edge-drags to whole cells - the same
151 /// mechanism terminal emulators use to snap to character cells.
152 /// The snap counts from [`Self::min_size`], which is already
153 /// cell-aligned, so every allowed size lands on a boundary.
154 /// Ignored when `can_resize()` returns `false`.
155 fn size_increment(&self) -> Option<(u32, u32)> {
156 None
157 }
158
159 /// Aspect-ratio constraint as `(numerator, denominator)`, or
160 /// `None` for free resizing. CLAP, VST3, AU v3, standalone, and
161 /// LV2 honour this; VST2 / AAX silently ignore. Integer pair
162 /// (not `f64`) avoids the Cubase-9 aspect-rounding quirk JUCE
163 /// special-cases.
164 fn aspect_ratio(&self) -> Option<(u32, u32)> {
165 None
166 }
167
168 /// Hint that the renderer prefers power-of-two surface sizes
169 /// (some GPU-backed editors). Maps onto CLAP's
170 /// `clap_gui_resize_hints.preserve_aspect_ratio` /
171 /// `aspect_ratio_width` siblings; ignored on formats without
172 /// an equivalent.
173 fn prefers_pow2(&self) -> bool {
174 false
175 }
176
177 /// Host notifies the editor of a new content scale factor.
178 ///
179 /// DPI/scale is a host→plugin concept: on VST3 Windows the host
180 /// delivers it via `IPlugViewContentScaleSupport`; on CLAP via
181 /// `clap_plugin_gui::set_scale`; on macOS/Cocoa `AppKit` handles
182 /// Retina backing automatically and hosts typically never call
183 /// this at all. Editors that need to size off-screen buffers in
184 /// physical pixels should react here, not by exposing a pull-style
185 /// `scale_factor()` method that format wrappers were tempted to
186 /// multiply `size()` by (which caused double-scaling on macOS VST3).
187 fn set_scale_factor(&mut self, _factor: f64) {}
188
189 /// Opt the editor into honoring the desktop (system) scale.
190 ///
191 /// The standalone app calls this with `true` before [`open`] because
192 /// it owns a real top-level window that should match the desktop
193 /// (`Xft.dpi` on Linux). Plugin formats leave the default: an
194 /// embedded editor drives its Linux scale from the host's
195 /// content-scale callback (default 1.0) instead of the desktop,
196 /// since a non-DPI-aware host (e.g. Bitwig on X11) runs at 1x
197 /// regardless of desktop scaling and would otherwise get a
198 /// double-sized window. No-op on macOS/Windows, where the OS
199 /// reports a reliable per-window scale.
200 ///
201 /// [`open`]: Editor::open
202 fn set_uses_system_scale(&mut self, _yes: bool) {}
203
204 /// Plugin state was restored (preset recall, undo, session load).
205 ///
206 /// Called after `load_state()` while the editor is open. Re-read any
207 /// cached state from the plugin. Parameter values are already updated
208 /// and will be picked up on the next render - this is only needed for
209 /// custom state stored outside the parameter system.
210 fn state_changed(&mut self) {}
211
212 /// Render a headless screenshot of the editor at its natural size.
213 ///
214 /// `params` is a type-erased default-state instance the caller
215 /// constructs from the plugin's `Params` type. Backends use it to
216 /// build a synthetic `PluginContext` / render context so the
217 /// screenshot reflects parameter defaults without needing a live
218 /// host.
219 ///
220 /// Returns `(rgba_pixels, physical_width, physical_height)` - RGBA8
221 /// row-major, ready to feed into `truce_test::assert_screenshot_pixels`.
222 /// Default impl returns `None`; backends that support headless
223 /// capture (built-in widgets, egui, iced, slint) override.
224 ///
225 /// Used by `truce_test::assert_screenshot::<Plugin>(...)` for one-line
226 /// snapshot regression tests. Editors backed by frameworks that
227 /// don't expose a headless render path (e.g. raw-window-handle
228 /// users wiring their own Metal/OpenGL) keep the default `None`.
229 fn screenshot(&mut self, params: Arc<dyn truce_params::Params>) -> Option<(Vec<u8>, u32, u32)> {
230 let _ = params;
231 None
232 }
233}
234
235/// Fluent terminal for `editor()` impls: box any concrete editor into
236/// the `Box<dyn Editor>` the trait returns, dropping the `Box::new(…)`
237/// wrapper.
238///
239/// ```ignore
240/// fn editor(&self) -> Box<dyn Editor> {
241/// EguiEditor::new(self.params.clone(), (W, H), ui)
242/// .with_visuals(theme)
243/// .into_editor()
244/// }
245/// ```
246///
247/// Implemented for every [`Editor`] via a blanket impl and re-exported
248/// from every `truce::prelude*`, so it's in scope without an extra
249/// import - egui / iced / slint / hand-rolled editors all use it.
250/// Layout-only plugins use `truce_gui::IntoLayoutEditor` instead (its
251/// `into_editor` takes `&Arc<Params>` and picks the built-in renderer).
252pub trait IntoEditor {
253 /// Box this editor into a `Box<dyn Editor>`.
254 fn into_editor(self) -> Box<dyn Editor>;
255}
256
257impl<E: Editor + 'static> IntoEditor for E {
258 fn into_editor(self) -> Box<dyn Editor> {
259 Box::new(self)
260 }
261}
262
263/// Bridge between the editor and the host / plugin. Format wrappers
264/// (CLAP / VST3 / VST2 / AU / AAX / LV2) implement this trait - or
265/// build a [`ClosureBridge`] from per-method closures - and pass an
266/// `Arc<dyn EditorBridge>` to the editor through [`PluginContext`].
267///
268/// Editors call into the bridge for everything they can't do
269/// directly: starting / ending an automation gesture, reading or
270/// writing parameters in normalized or plain form, requesting a
271/// window resize, exchanging custom state, sampling the host's
272/// transport. Implementations carry whatever per-format pointers
273/// the work needs (`clap_host*`, `AEffect*`, an `Arc<P>` for the
274/// param store, etc.).
275///
276/// `Send + Sync` is required so editors can clone the
277/// `Arc<dyn EditorBridge>` and hand it to UI worker threads or
278/// background animation timers without forcing every implementor to
279/// rederive thread-safety bounds.
280pub trait EditorBridge: Send + Sync {
281 /// Start an automation gesture for `id`. Hosts that show "touched"
282 /// state in the automation lane use this to render the
283 /// in-progress edit.
284 fn begin_edit(&self, id: u32);
285 /// Set parameter `id` to `normalized` (clamped to `0.0..=1.0`).
286 /// Format wrappers usually plumb this through both the plugin's
287 /// own param store and the host's automation channel.
288 fn set_param(&self, id: u32, normalized: f64);
289 /// End the automation gesture started by [`Self::begin_edit`].
290 fn end_edit(&self, id: u32);
291 /// Ask the host to resize the editor window to `(w, h)` logical
292 /// points. Returns `true` if the host accepted the request.
293 fn request_resize(&self, w: u32, h: u32) -> bool;
294 /// Read the parameter's current normalized value from the plugin
295 /// (host→GUI sync path).
296 fn get_param(&self, id: u32) -> f64;
297 /// Read the parameter's current plain (denormalized) value.
298 fn get_param_plain(&self, id: u32) -> f64;
299 /// Format the parameter's current value as a display string,
300 /// applying the plugin's `format_value` impl + unit suffix.
301 fn format_param(&self, id: u32) -> String;
302 /// Format into a caller-provided buffer instead of returning a
303 /// fresh `String`. The default impl calls
304 /// [`Self::format_param`] and copies, so the *bridge-internal*
305 /// allocation still happens; the win for the caller is that the
306 /// `out` buffer's capacity is reused across calls (e.g.
307 /// `ParamCache::sync` polls one label per changed param per
308 /// frame and would otherwise drop+reallocate the cached
309 /// `String` slot every time). Bridges that produce the formatted
310 /// string from raw value bytes can override to drop the
311 /// internal allocation too.
312 fn format_param_into(&self, id: u32, out: &mut String) {
313 out.clear();
314 out.push_str(&self.format_param(id));
315 }
316 /// Read a meter value (0.0–1.0) by meter ID. Returns 0.0 if the
317 /// meter ID isn't registered.
318 fn get_meter(&self, id: u32) -> f32;
319 /// Read the plugin's custom state (everything outside the
320 /// parameter system). Returns an empty `Vec` when the plugin has
321 /// no custom state.
322 fn get_state(&self) -> Vec<u8>;
323 /// Write custom state back to the plugin (calls `load_state()`).
324 fn set_state(&self, data: Vec<u8>);
325 /// Most-recently-reported host transport state, or `None` if the
326 /// host does not expose transport to plugin editors or the plugin
327 /// has not yet received a process block.
328 ///
329 /// Format wrappers populate a shared [`TransportSlot`](crate::TransportSlot)
330 /// from their process callback; this method reads from it.
331 fn transport(&self) -> Option<TransportInfo>;
332}
333
334/// Adapter that implements [`EditorBridge`] over per-method closures.
335///
336/// Format wrappers that prefer to compose state inline via closures
337/// construct one of these and wrap it in an `Arc<dyn EditorBridge>`.
338/// Wrappers that already have a typed host-pointer struct should
339/// `impl EditorBridge` for that struct directly and skip this
340/// adapter; one less layer of indirection per call.
341pub struct ClosureBridge {
342 pub begin_edit: Box<dyn Fn(u32) + Send + Sync>,
343 pub set_param: Box<dyn Fn(u32, f64) + Send + Sync>,
344 pub end_edit: Box<dyn Fn(u32) + Send + Sync>,
345 pub request_resize: Box<dyn Fn(u32, u32) -> bool + Send + Sync>,
346 pub get_param: Box<dyn Fn(u32) -> f64 + Send + Sync>,
347 pub get_param_plain: Box<dyn Fn(u32) -> f64 + Send + Sync>,
348 pub format_param: Box<dyn Fn(u32) -> String + Send + Sync>,
349 pub get_meter: Box<dyn Fn(u32) -> f32 + Send + Sync>,
350 pub get_state: Box<dyn Fn() -> Vec<u8> + Send + Sync>,
351 pub set_state: Box<dyn Fn(Vec<u8>) + Send + Sync>,
352 pub transport: Box<dyn Fn() -> Option<TransportInfo> + Send + Sync>,
353}
354
355impl EditorBridge for ClosureBridge {
356 fn begin_edit(&self, id: u32) {
357 (self.begin_edit)(id);
358 }
359 fn set_param(&self, id: u32, normalized: f64) {
360 (self.set_param)(id, normalized);
361 }
362 fn end_edit(&self, id: u32) {
363 (self.end_edit)(id);
364 }
365 fn request_resize(&self, w: u32, h: u32) -> bool {
366 (self.request_resize)(w, h)
367 }
368 fn get_param(&self, id: u32) -> f64 {
369 (self.get_param)(id)
370 }
371 fn get_param_plain(&self, id: u32) -> f64 {
372 (self.get_param_plain)(id)
373 }
374 fn format_param(&self, id: u32) -> String {
375 (self.format_param)(id)
376 }
377 fn get_meter(&self, id: u32) -> f32 {
378 (self.get_meter)(id)
379 }
380 fn get_state(&self) -> Vec<u8> {
381 (self.get_state)()
382 }
383 fn set_state(&self, data: Vec<u8>) {
384 (self.set_state)(data);
385 }
386 fn transport(&self) -> Option<TransportInfo> {
387 (self.transport)()
388 }
389}
390
391/// Context passed to [`Editor::open`]. Carries:
392///
393/// - An `Arc<dyn EditorBridge>` - the host-plugin protocol surface
394/// (begin/set/end edit, `request_resize`, `get_state`, transport, …).
395/// - An `Arc<P>` typed parameter store - plugin authors `Deref` to
396/// `&P` and read fields directly: `state.gain.read()`.
397///
398/// The default `P = dyn Params` keeps the trait-object boundary
399/// (`Editor::open(ctx: PluginContext)`) one-typed; editor crates
400/// that want typed access (truce-egui, truce-slint, truce-iced) carry
401/// their own `<P>` and reconstitute `PluginContext<P>` internally
402/// via [`PluginContext::with_params`] using the `Arc<P>` they stored
403/// at editor construction.
404///
405/// `Clone` is two refcount bumps (bridge + params). Editors that need
406/// to hand the context to UI worker threads or animation timers clone
407/// freely.
408pub struct PluginContext<P: ?Sized = dyn Params> {
409 bridge: Arc<dyn EditorBridge>,
410 params: Arc<P>,
411}
412
413impl<P: ?Sized> Clone for PluginContext<P> {
414 fn clone(&self) -> Self {
415 Self {
416 bridge: Arc::clone(&self.bridge),
417 params: Arc::clone(&self.params),
418 }
419 }
420}
421
422impl<P: ?Sized> PluginContext<P> {
423 /// Build a typed context from any [`EditorBridge`] implementor and
424 /// the plugin's typed param store.
425 pub fn new(bridge: Arc<dyn EditorBridge>, params: Arc<P>) -> Self {
426 Self { bridge, params }
427 }
428
429 /// Access the underlying bridge handle. Editors that want to clone
430 /// the bridge into a worker thread without cloning the surrounding
431 /// `PluginContext` use this.
432 #[must_use]
433 pub fn bridge(&self) -> &Arc<dyn EditorBridge> {
434 &self.bridge
435 }
436
437 /// Access the typed param store as an `Arc`. Use this when you
438 /// need to capture the params in a `'static` closure (e.g. an iced
439 /// `Subscription` or a worker thread).
440 #[must_use]
441 pub fn params(&self) -> &Arc<P> {
442 &self.params
443 }
444
445 /// Replace the param-store generic parameter while reusing the
446 /// same bridge. Used by editor crates that receive the dyn-erased
447 /// `PluginContext` from [`Editor::open`] and want the typed
448 /// `PluginContext<P>` for their UI closure.
449 pub fn with_params<Q: ?Sized>(&self, params: Arc<Q>) -> PluginContext<Q> {
450 PluginContext {
451 bridge: Arc::clone(&self.bridge),
452 params,
453 }
454 }
455
456 pub fn begin_edit(&self, id: impl Into<u32>) {
457 self.bridge.begin_edit(id.into());
458 }
459 pub fn set_param(&self, id: impl Into<u32>, normalized: f64) {
460 self.bridge.set_param(id.into(), normalized);
461 }
462 pub fn end_edit(&self, id: impl Into<u32>) {
463 self.bridge.end_edit(id.into());
464 }
465 /// Begin + set + end in one call. Use for click-to-toggle widgets
466 /// and similar single-shot edits where the gesture and the value
467 /// arrive together.
468 pub fn automate(&self, id: impl Into<u32>, normalized: f64) {
469 let id = id.into();
470 self.bridge.begin_edit(id);
471 self.bridge.set_param(id, normalized);
472 self.bridge.end_edit(id);
473 }
474 #[must_use]
475 pub fn request_resize(&self, w: u32, h: u32) -> bool {
476 self.bridge.request_resize(w, h)
477 }
478 pub fn format_param(&self, id: impl Into<u32>) -> String {
479 self.bridge.format_param(id.into())
480 }
481 /// Format into a caller-owned buffer. See
482 /// [`EditorBridge::format_param_into`] for the allocation
483 /// trade-off - the caller's buffer is reused, but bridges that
484 /// don't override the default impl still allocate internally.
485 pub fn format_param_into(&self, id: impl Into<u32>, out: &mut String) {
486 self.bridge.format_param_into(id.into(), out);
487 }
488 pub fn get_meter(&self, id: impl Into<u32>) -> f32 {
489 self.bridge.get_meter(id.into())
490 }
491 #[must_use]
492 pub fn get_state(&self) -> Vec<u8> {
493 self.bridge.get_state()
494 }
495 pub fn set_state(&self, data: Vec<u8>) {
496 self.bridge.set_state(data);
497 }
498 #[must_use]
499 pub fn transport(&self) -> Option<TransportInfo> {
500 self.bridge.transport()
501 }
502}
503
504impl PluginContext<dyn Params> {
505 /// Build a dyn-erased context from a [`ClosureBridge`]. Convenience
506 /// for format wrappers that compose state inline via closures.
507 pub fn from_closures(bridge: ClosureBridge, params: Arc<dyn Params>) -> Self {
508 Self {
509 bridge: Arc::new(bridge),
510 params,
511 }
512 }
513}
514
515impl<P: Params + 'static> PluginContext<P> {
516 /// Drop the typed `<P>` and return the dyn-erased context that
517 /// crosses the `Editor::open` trait-object boundary.
518 #[must_use]
519 pub fn dyn_erase(self) -> PluginContext<dyn Params> {
520 PluginContext {
521 bridge: self.bridge,
522 params: self.params as Arc<dyn Params>,
523 }
524 }
525}
526
527/// Plugin authors read parameter fields directly via `Deref`:
528/// `state.gain.read()`, `state.bypass.value()`. The `state`
529/// here is `&PluginContext<MyParams>` and `Deref::Target = MyParams`.
530impl<P: ?Sized> Deref for PluginContext<P> {
531 type Target = P;
532 fn deref(&self) -> &P {
533 &self.params
534 }
535}
536
537/// Build a [`PluginContext`] backed only by `params`. All write
538/// closures are no-ops; reads delegate to the params `Arc`; the
539/// transport reports the deterministic
540/// [`crate::events::TransportInfo::for_screenshot`] state so
541/// screenshot tests stay reproducible across CI runs.
542///
543/// Used by editor backends inside their `Editor::screenshot()` impl,
544/// and re-exported from `truce-test` for plugin authors that want to
545/// drive snapshot tests directly.
546pub fn for_test_params(params: Arc<dyn Params>) -> PluginContext<dyn Params> {
547 let p_get = Arc::clone(¶ms);
548 let p_plain = Arc::clone(¶ms);
549 let p_fmt = Arc::clone(¶ms);
550 let transport = TransportInfo::for_screenshot();
551 PluginContext::from_closures(
552 ClosureBridge {
553 begin_edit: Box::new(|_| {}),
554 set_param: Box::new(|_, _| {}),
555 end_edit: Box::new(|_| {}),
556 request_resize: Box::new(|_, _| false),
557 get_param: Box::new(move |id| p_get.get_normalized(id).unwrap_or(0.5)),
558 get_param_plain: Box::new(move |id| p_plain.get_plain(id).unwrap_or(0.0)),
559 format_param: Box::new(move |id| {
560 let plain = p_fmt.get_plain(id).unwrap_or(0.0);
561 p_fmt
562 .format_value(id, plain)
563 .unwrap_or_else(|| format!("{plain:.2}"))
564 }),
565 get_meter: Box::new(|_| 0.0),
566 get_state: Box::new(Vec::new),
567 set_state: Box::new(|_| {}),
568 transport: Box::new(move || Some(transport)),
569 },
570 params,
571 )
572}
573
574// ---------------------------------------------------------------------------
575// Precision-routed parameter reads
576//
577// The editor-bridge surface is sample-agnostic (`f64` on the wire, the
578// lossless lowest-common-denominator that round-trips any host
579// automation precision). These two extension traits route the call
580// site to the user's chosen precision - same pattern as
581// `FloatParamReadF32` / `FloatParamReadF64` for the audio-thread
582// param reads. Brought into scope via `pub use ... as _;` in each
583// prelude:
584// - `prelude` / `prelude32` → `PluginContextReadF32`
585// - `prelude64` / `prelude64m` → `PluginContextReadF64`
586//
587// Single-prelude code dispatches unambiguously. Importing both
588// preludes in the same file collides on `get_param` - the right
589// error if the file hasn't committed to a precision.
590// ---------------------------------------------------------------------------
591
592/// `f32`-precision parameter reads on `PluginContext`. Brought into
593/// scope by `truce::prelude` / `truce::prelude32` / `truce::prelude64m`
594/// (the `f32`-buffer preludes). GUI binding crates (slint, egui,
595/// iced) take `f32` natively, so this is the common case.
596pub trait PluginContextReadF32 {
597 /// Normalized `[0, 1]` value of the parameter, narrowed to `f32`.
598 fn get_param(&self, id: impl Into<u32>) -> f32;
599 /// Plain (denormalized) value of the parameter, narrowed to `f32`.
600 fn get_param_plain(&self, id: impl Into<u32>) -> f32;
601}
602
603/// `f64`-precision parameter reads on `PluginContext`. Brought into
604/// scope by `truce::prelude64`. Same surface as
605/// [`PluginContextReadF32`] but returns the bridge's `f64` value
606/// directly without narrowing.
607pub trait PluginContextReadF64 {
608 /// Normalized `[0, 1]` value of the parameter.
609 fn get_param(&self, id: impl Into<u32>) -> f64;
610 /// Plain (denormalized) value of the parameter.
611 fn get_param_plain(&self, id: impl Into<u32>) -> f64;
612}
613
614impl<P: ?Sized> PluginContextReadF32 for PluginContext<P> {
615 fn get_param(&self, id: impl Into<u32>) -> f32 {
616 self.bridge.get_param(id.into()).to_f32()
617 }
618 fn get_param_plain(&self, id: impl Into<u32>) -> f32 {
619 self.bridge.get_param_plain(id.into()).to_f32()
620 }
621}
622
623impl<P: ?Sized> PluginContextReadF64 for PluginContext<P> {
624 fn get_param(&self, id: impl Into<u32>) -> f64 {
625 self.bridge.get_param(id.into())
626 }
627 fn get_param_plain(&self, id: impl Into<u32>) -> f64 {
628 self.bridge.get_param_plain(id.into())
629 }
630}
631
632/// Constrain a host-requested logical size to an editor's
633/// [`Editor::min_size`] / [`Editor::max_size`] / [`Editor::aspect_ratio`].
634/// Shared by every format wrapper so they enforce identical constraints.
635///
636/// With an aspect ratio set, fit the *largest on-ratio rectangle that fits
637/// inside* the requested box: derive height from width and keep it if it
638/// fits, otherwise the width is the limiting axis and height drives it. The
639/// result is `<=` the request on both axes, so the editor surface never
640/// exceeds the host window and can never clip - whatever odd size a host
641/// hands us (some skip an aspect pre-flight and pass raw drag dimensions),
642/// the worst case is an on-ratio letterbox inside the window. The rule is a
643/// pure function of `(w, h)` - no "which edge moved" guess - so a drag can't
644/// make the chosen axis flip and judder. `u64` arithmetic for the
645/// multiplication so a hypothetical `(u32::MAX, 1)` aspect doesn't overflow
646/// before the clamp lands.
647#[must_use]
648pub fn fit_logical_size(w: u32, h: u32, editor: &dyn Editor) -> (u32, u32) {
649 fit_size(
650 w,
651 h,
652 editor.min_size(),
653 editor.max_size(),
654 editor.aspect_ratio(),
655 )
656}
657
658/// Same fit as [`fit_logical_size`] but over raw constraints rather than
659/// an `&dyn Editor`. Lets call sites that have already captured the
660/// bounds (e.g. an Objective-C resize callback that can't carry a trait
661/// object) reuse the identical rule.
662#[must_use]
663pub fn fit_size(
664 w: u32,
665 h: u32,
666 min: (u32, u32),
667 max: (u32, u32),
668 aspect: Option<(u32, u32)>,
669) -> (u32, u32) {
670 let (min_w, min_h) = min;
671 let (max_w, max_h) = max;
672 let mut w = w.clamp(min_w.max(1), max_w);
673 let mut h = h.clamp(min_h.max(1), max_h);
674 if let Some((num64, denom64)) = ratio64(aspect) {
675 // The on-ratio height for this width. Unclamped so the comparison
676 // sees the true ratio, not a bound-pinned value.
677 let h_from_w = u64::from(w) * denom64 / num64;
678 if h_from_w <= u64::from(h) {
679 // Width is the limiting axis: shrink height onto the ratio.
680 h = derive_height(w, min_h, max_h, num64, denom64).0;
681 } else {
682 // Height is the limiting axis: shrink width onto the ratio.
683 w = derive_width(h, min_w, max_w, num64, denom64).0;
684 }
685 }
686 (w, h)
687}
688
689/// Clamp a host-committed size to the editor's `[min, max]` bounds only,
690/// leaving the aspect ratio untouched so the editor fills the host window
691/// exactly. The commit-time counterpart to [`fit_logical_size`]: on-ratio
692/// shaping already happened during the host's drag negotiation (a preflight
693/// such as VST3 `checkSizeConstraint`), so re-fitting onto the ratio here
694/// would only floor the editor a pixel under the window and leave an
695/// unpainted letterbox line. The `max` clamp keeps the surface inside the
696/// window; the `min` clamp upholds the editor's "can't render smaller than
697/// this" floor even when a host hands over a too-small box.
698#[must_use]
699pub fn clamp_logical_size(w: u32, h: u32, editor: &dyn Editor) -> (u32, u32) {
700 let (min_w, min_h) = editor.min_size();
701 let (max_w, max_h) = editor.max_size();
702 (w.clamp(min_w.max(1), max_w), h.clamp(min_h.max(1), max_h))
703}
704
705/// Enforces size constraints on host resizes that bypassed the format's
706/// negotiation hooks. Some hosts resize the plugin's embedded window
707/// directly at the windowing-system level (Bitwig on Linux/X11 resizes
708/// the embed window itself), so no `checkSizeConstraint`-style preflight
709/// ever runs - the editor's own `Resized` handler is the last place that
710/// can enforce `min_size` / `max_size` / `aspect_ratio`.
711///
712/// [`Self::fit`] returns the size the editor should render at, plus an
713/// optional corrective size to push back to the host
714/// (`PluginContext::request_resize`). Each offending host size triggers at
715/// most one correction, and the corrective size itself satisfies the
716/// constraints, so a host that refuses (or echoes) the request can't be
717/// spun into a resize feedback loop.
718#[derive(Default)]
719pub struct ResizeCorrector {
720 /// Whether we've already pushed a corrective resize back to the host
721 /// for the current out-of-bounds excursion. Latches on the first
722 /// push-back and clears only when the host hands us an in-bounds size
723 /// again. A host that bypasses negotiation and then ignores the
724 /// push-back would otherwise be re-asked every frame and spun into a
725 /// runaway resize loop: Bitwig on Linux returns success to
726 /// `request_resize` but instead *grows* the embed window a few px per
727 /// call, and jitters the size on the un-clamped axis so the fitted
728 /// target changes every frame. Latching on "have we asked since the
729 /// last in-bounds size" - rather than on the requested size - sends
730 /// exactly one request per excursion even when that target wobbles.
731 pushed_back: bool,
732}
733
734impl ResizeCorrector {
735 /// Fit a host-driven logical size against the constraints. Returns
736 /// the fitted size to render at and, when the host size was out of
737 /// bounds and we haven't already pushed back this excursion, the size
738 /// to request back.
739 pub fn fit(
740 &mut self,
741 w: u32,
742 h: u32,
743 min: (u32, u32),
744 max: (u32, u32),
745 aspect: Option<(u32, u32)>,
746 ) -> ((u32, u32), Option<(u32, u32)>) {
747 let fitted = fit_size(w, h, min, max, aspect);
748 if fitted == (w, h) {
749 // In-bounds: the host is cooperating (or the drag returned
750 // within bounds); re-arm the next excursion's one-shot push.
751 self.pushed_back = false;
752 return (fitted, None);
753 }
754 // Out of bounds: push back exactly once per excursion.
755 let request = (!self.pushed_back).then_some(fitted);
756 self.pushed_back = true;
757 (fitted, request)
758 }
759}
760
761/// A usable `(num, denom)` ratio as `u64`, or `None` when no aspect is set
762/// or either term is zero. `u64` so the on-ratio multiplications below can't
763/// overflow before the clamp lands (a hypothetical `(u32::MAX, 1)` aspect).
764fn ratio64(aspect: Option<(u32, u32)>) -> Option<(u64, u64)> {
765 match aspect {
766 Some((num, denom)) if num > 0 && denom > 0 => Some((u64::from(num), u64::from(denom))),
767 _ => None,
768 }
769}
770
771/// On-ratio height for `w`, clamped into `[min_h, max_h]`. The flag reports
772/// whether the clamp moved the value (i.e. a bound was hit), so the caller
773/// knows whether the source axis needs re-deriving to stay on-ratio.
774#[allow(clippy::cast_possible_truncation)]
775fn derive_height(w: u32, min_h: u32, max_h: u32, num64: u64, denom64: u64) -> (u32, bool) {
776 let on_ratio = (u64::from(w) * denom64 / num64).clamp(1, u64::from(u32::MAX)) as u32;
777 let clamped = on_ratio.clamp(min_h.max(1), max_h);
778 (clamped, clamped != on_ratio)
779}
780
781/// On-ratio width for `h`, clamped into `[min_w, max_w]`. Flag as in
782/// [`derive_height`].
783#[allow(clippy::cast_possible_truncation)]
784fn derive_width(h: u32, min_w: u32, max_w: u32, num64: u64, denom64: u64) -> (u32, bool) {
785 let on_ratio = (u64::from(h) * num64 / denom64).clamp(1, u64::from(u32::MAX)) as u32;
786 let clamped = on_ratio.clamp(min_w.max(1), max_w);
787 (clamped, clamped != on_ratio)
788}
789
790#[cfg(test)]
791mod corrector_tests {
792 use super::ResizeCorrector;
793
794 const MIN: (u32, u32) = (300, 200);
795 const MAX: (u32, u32) = (900, 600);
796
797 #[test]
798 fn in_bounds_size_passes_through_without_correction() {
799 let mut c = ResizeCorrector::default();
800 assert_eq!(c.fit(400, 300, MIN, MAX, None), ((400, 300), None));
801 }
802
803 #[test]
804 fn out_of_bounds_pushes_once_per_excursion() {
805 let mut c = ResizeCorrector::default();
806 // First out-of-bounds sight: fit + one push-back.
807 let (fitted, req) = c.fit(1200, 800, MIN, MAX, None);
808 assert_eq!(fitted, (900, 600));
809 assert_eq!(req, Some((900, 600)));
810 // Host refused / echoed the same size: no repeat request.
811 assert_eq!(c.fit(1200, 800, MIN, MAX, None), ((900, 600), None));
812 // Host ignores it and keeps feeding out-of-bounds sizes - crucially,
813 // even ones whose *fitted target wobbles* (a different clamp on the
814 // un-pinned axis each frame). We stay quiet, so a host that grows in
815 // response to each request (Bitwig) can't be spun into a runaway.
816 assert_eq!(c.fit(1300, 590, MIN, MAX, None), ((900, 590), None));
817 assert_eq!(c.fit(1300, 595, MIN, MAX, None), ((900, 595), None));
818 // ...even a swing to the opposite bound stays quiet mid-excursion.
819 assert_eq!(c.fit(100, 100, MIN, MAX, None), ((300, 200), None));
820 // Host finally hands us an in-bounds size: excursion over, re-arm.
821 assert_eq!(c.fit(800, 500, MIN, MAX, None), ((800, 500), None));
822 // Next excursion gets a fresh single push-back.
823 let (_, req) = c.fit(1200, 800, MIN, MAX, None);
824 assert_eq!(req, Some((900, 600)));
825 }
826
827 #[test]
828 fn honoured_correction_resets_the_guard() {
829 let mut c = ResizeCorrector::default();
830 let _ = c.fit(1200, 800, MIN, MAX, None);
831 // Host applied the corrective size: in bounds, guard resets...
832 assert_eq!(c.fit(900, 600, MIN, MAX, None), ((900, 600), None));
833 // ...so the same offending size requests again next time.
834 let (_, req) = c.fit(1200, 800, MIN, MAX, None);
835 assert_eq!(req, Some((900, 600)));
836 }
837
838 #[test]
839 fn aspect_violation_corrects_onto_ratio() {
840 let mut c = ResizeCorrector::default();
841 let ((w, h), req) = c.fit(800, 600, MIN, MAX, Some((4, 3)));
842 assert_eq!((w, h), (800, 600), "already on-ratio passes through");
843 assert_eq!(req, None);
844 let ((w, h), req) = c.fit(800, 400, MIN, MAX, Some((4, 3)));
845 assert_eq!((w, h), (533, 400), "height-limited fit onto 4:3");
846 assert_eq!(req, Some((533, 400)));
847 }
848}
849
850#[cfg(test)]
851mod fit_tests {
852 use super::{Editor, PluginContext, RawWindowHandle, fit_logical_size};
853
854 /// Minimal editor stub: only the bounds/aspect hooks
855 /// `fit_logical_size` reads carry meaning; the rest are unused.
856 struct StubEditor {
857 min: (u32, u32),
858 max: (u32, u32),
859 aspect: Option<(u32, u32)>,
860 }
861
862 impl Editor for StubEditor {
863 fn size(&self) -> (u32, u32) {
864 self.min
865 }
866 fn open(&mut self, _parent: RawWindowHandle, _context: PluginContext) {}
867 fn close(&mut self) {}
868 fn min_size(&self) -> (u32, u32) {
869 self.min
870 }
871 fn max_size(&self) -> (u32, u32) {
872 self.max
873 }
874 fn aspect_ratio(&self) -> Option<(u32, u32)> {
875 self.aspect
876 }
877 }
878
879 fn stub(aspect: Option<(u32, u32)>) -> StubEditor {
880 StubEditor {
881 min: (320, 240),
882 max: (u32::MAX, u32::MAX),
883 aspect,
884 }
885 }
886
887 #[test]
888 fn no_aspect_clamps_each_axis_to_bounds() {
889 let e = stub(None);
890 assert_eq!(fit_logical_size(800, 600, &e), (800, 600));
891 assert_eq!(fit_logical_size(100, 100, &e), (320, 240));
892 }
893
894 #[test]
895 fn tall_box_is_width_bound() {
896 // A box taller than 4:3 fits the full width; height shrinks onto
897 // the ratio so the result never overflows the box.
898 let e = stub(Some((4, 3)));
899 assert_eq!(fit_logical_size(640, 800, &e), (640, 480));
900 }
901
902 #[test]
903 fn wide_box_is_height_bound() {
904 // A box wider than 4:3 fits the full height; width shrinks instead.
905 let e = stub(Some((4, 3)));
906 assert_eq!(fit_logical_size(800, 480, &e), (640, 480));
907 }
908
909 #[test]
910 fn on_ratio_box_is_unchanged() {
911 let e = stub(Some((4, 3)));
912 assert_eq!(fit_logical_size(800, 600, &e), (800, 600));
913 }
914
915 #[test]
916 fn fit_never_exceeds_the_requested_box() {
917 // The no-clip invariant: for any box at or above `min_size`, the
918 // aspect fit stays inside it on both axes, so the editor surface
919 // can never overflow the host window. `min` is on the 16:9 ratio so
920 // the fit also stays exactly on-ratio right down to the corner.
921 let e = StubEditor {
922 min: (320, 180),
923 max: (u32::MAX, u32::MAX),
924 aspect: Some((16, 9)),
925 };
926 for &(w, h) in &[
927 (640, 800),
928 (800, 480),
929 (1000, 1000),
930 (321, 900),
931 (1920, 300),
932 ] {
933 let (rw, rh) = fit_logical_size(w, h, &e);
934 assert!(rw <= w && rh <= h, "{rw}x{rh} exceeds box {w}x{h}");
935 assert!((i64::from(rw) * 9 - i64::from(rh) * 16).abs() <= 16);
936 assert!(rw >= 320 && rh >= 180);
937 }
938 }
939}