Skip to main content

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