Skip to main content

truce_gui/
editor.rs

1//! Built-in editor using the CPU render backend.
2//!
3//! Renders parameter widgets via `RenderBackend`. Uses tiny-skia for
4//! software rasterization and baseview + wgpu for window management
5//! and blitting. For GPU-accelerated rendering see the `truce-gpu`
6//! crate which provides `GpuEditor` wrapping this editor.
7
8#[cfg(feature = "cpu")]
9use std::ptr;
10use std::sync::Arc;
11#[cfg(feature = "cpu")]
12use std::sync::Mutex;
13#[cfg(feature = "cpu")]
14use std::sync::atomic::AtomicU64;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use truce_core::Float;
18#[cfg(feature = "cpu")]
19use truce_core::editor::RawWindowHandle;
20#[cfg(feature = "cpu")]
21use truce_core::editor::{Editor, ResizeCorrector};
22use truce_core::editor::{PluginContext, PluginContextReadF32};
23use truce_params::Params;
24
25#[cfg(feature = "cpu")]
26use crate::backend_cpu::CpuBackend;
27use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
28use crate::layout::{GridLayout, Layout, PluginLayout};
29#[cfg(feature = "cpu")]
30use crate::platform::EditorScale;
31use crate::render::RenderBackend;
32use crate::render_core::{
33    EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
34    render_widgets as render_widgets_impl,
35};
36use crate::theme::Theme;
37use crate::widgets;
38
39/// Built-in editor that renders parameter widgets to a pixel buffer.
40///
41/// Uses the CPU backend (tiny-skia) for software rasterization. When
42/// `open()` is called, creates a baseview window and blits pixels via wgpu.
43pub struct BuiltinEditor<P: Params> {
44    params: Arc<P>,
45    layout: Layout,
46    theme: Theme,
47    /// CPU pixmap rendering target. Only present when the `cpu`
48    /// feature is on; in `gpu`-only mode `BuiltinEditor` is wrapped
49    /// by `GpuEditor`, which renders through `WgpuBackend` directly
50    /// via [`Self::render_to`] without touching this field.
51    #[cfg(feature = "cpu")]
52    backend: Option<CpuBackend>,
53    interaction: InteractionState,
54    context: Option<PluginContext>,
55    /// Active baseview window handle for the cpu-path `Editor`
56    /// impl. Only meaningful when `cpu` is on.
57    #[cfg(feature = "cpu")]
58    window: Option<baseview::WindowHandle>,
59    /// Weak-ish handle to the blit backend the window-handler
60    /// materializes. The editor keeps the canonical `Arc` and the
61    /// handler gets a clone. On close we take the `Option` out of
62    /// the inner mutex - dropping the wgpu Surface synchronously -
63    /// before asking baseview to tear the `NSView` down.
64    #[cfg(feature = "cpu")]
65    blit_backend: Option<SharedBackend>,
66    /// Set whenever something visible changes (param edited via the
67    /// UI, host-driven state reload, explicit `request_repaint` by
68    /// plugin code). `on_frame` clears it and only does the
69    /// rasterize + blit pass when it was true.
70    ///
71    /// Shared so `PluginContext::set_param` and `state_changed`
72    /// closures can flip it without touching editor internals.
73    needs_repaint: Arc<AtomicBool>,
74    /// Normalized values captured at the last render pass, in the
75    /// same order as `interaction.knob_regions`. Used to detect
76    /// host-driven param changes (automation, preset recall) - if any
77    /// live value drifts from the last-painted one, we force a
78    /// repaint even if the UI never received a direct edit. Only
79    /// the cpu path's incremental render uses this signal.
80    #[cfg(feature = "cpu")]
81    last_painted_values: Vec<f32>,
82    /// Live content-scale factor (a [`crate::platform::EditorScale`]).
83    /// `set_scale_factor` (host) writes the cell; the baseview
84    /// handler holds a clone, compares against `last_applied_scale`
85    /// each frame, and rebuilds the CPU pixmap + reconfigures the
86    /// wgpu surface when the value diverges. Only consumed by the
87    /// cpu path; in gpu-only mode `GpuEditor` has its own
88    /// `EditorScale` and this field is unused.
89    #[cfg(feature = "cpu")]
90    scale: EditorScale,
91    /// Standalone hosts set this (via `set_uses_system_scale`) so the
92    /// editor honors the desktop `Xft.dpi` scale on Linux; plugins leave
93    /// it false and drive scale from the host instead. See
94    /// [`crate::platform::editor_window_scale`]. No effect off Linux.
95    #[cfg(feature = "cpu")]
96    use_system_scale: bool,
97    /// Whether the host announced a content scale via `set_scale_factor`.
98    /// On Linux this gates whether an embedded editor trusts `scale`
99    /// (host-announced) or defaults to 1.0.
100    #[cfg(feature = "cpu")]
101    host_scale_set: bool,
102    /// Meter IDs referenced by the layout, collected once at
103    /// construction. Meters are display-only values written from the
104    /// audio thread (`PluginContext::get_meter`); they never move
105    /// through the param system, so the CPU repaint gate needs to poll
106    /// them explicitly to know when to redraw. Empty for layouts with
107    /// no meters - the poll then short-circuits.
108    #[cfg(feature = "cpu")]
109    meter_ids: Vec<u32>,
110    /// Meter values captured at the last repaint, parallel to
111    /// `meter_ids`. `detect_meter_changes` compares the live values
112    /// against these to flip the dirty bit only when a meter actually
113    /// moved (the gpu path repaints unconditionally and ignores this).
114    #[cfg(feature = "cpu")]
115    last_meter_values: Vec<f32>,
116    /// Host-driven resize handoff. `Editor::set_size` snaps the
117    /// requested width to a whole number of `cell_size + gap`
118    /// steps, reflows the grid via `GridLayout::refit_cols`, and
119    /// packs the resulting `(w, h)` here. `on_frame` drains the
120    /// cell at the top of each tick and applies the size to the
121    /// CPU pixmap, wgpu surface, interaction regions, and the
122    /// baseview window itself - same handoff shape the egui / iced
123    /// / slint editors use. `0` is the "no pending resize"
124    /// sentinel; an unchanged editor pays one atomic load per
125    /// frame.
126    #[cfg(feature = "cpu")]
127    pending_size: Arc<AtomicU64>,
128}
129
130// SAFETY: `baseview::WindowHandle` holds a raw native window pointer
131// (HWND / NSView / X11 Window) and is not auto-`Send`. Hosts call
132// `Editor::open` / `idle` / `close` from a single dedicated GUI thread
133// - never concurrently and never from the audio thread - so the
134// handle is only ever touched on the thread that created it. The
135// `Editor` trait requires `Send` so the editor can live behind a
136// trait object; this impl asserts that the type doesn't escape its
137// thread in practice. All other fields (`Arc<P>`, `Layout`, `Theme`,
138// `Option<CpuBackend>`, etc.) are themselves `Send`.
139unsafe impl<P: Params> Send for BuiltinEditor<P> {}
140
141/// Gather every meter ID referenced by a layout, in layout order. The
142/// CPU editor polls these each frame to decide when a meter moved and
143/// the surface needs a repaint.
144#[cfg(feature = "cpu")]
145fn collect_meter_ids(layout: &Layout) -> Vec<u32> {
146    let mut ids = Vec::new();
147    match layout {
148        Layout::Rows(pl) => {
149            for row in &pl.rows {
150                for knob in &row.knobs {
151                    if let Some(m) = &knob.meter_ids {
152                        ids.extend_from_slice(m);
153                    }
154                }
155            }
156        }
157        Layout::Grid(gl) => {
158            for widget in &gl.widgets {
159                if let Some(m) = &widget.meter_ids {
160                    ids.extend_from_slice(m);
161                }
162            }
163        }
164    }
165    ids
166}
167
168impl<P: Params + 'static> BuiltinEditor<P> {
169    /// Request a repaint on the next idle tick. Call this if plugin
170    /// code mutates display state outside the normal param or
171    /// `state_changed` pathways (uncommon). User interaction and
172    /// host automation already flag themselves dirty automatically.
173    pub fn request_repaint(&self) {
174        self.needs_repaint.store(true, Ordering::Release);
175    }
176
177    /// Only consumed by the cpu Editor impl's render gate.
178    #[cfg(feature = "cpu")]
179    fn take_needs_repaint(&self) -> bool {
180        self.needs_repaint.swap(false, Ordering::AcqRel)
181    }
182
183    /// Compare the values just read by `update_interaction` (live from
184    /// the host / params Arc) against those captured at the last
185    /// render. A mismatch means an automation lane wrote a new value,
186    /// a preset was recalled, or some other off-UI state change
187    /// happened - force a repaint so the widget tracks it.
188    ///
189    /// Only used by the cpu blit path's incremental render gate;
190    /// the gpu path repaints every frame and skips this check.
191    #[cfg(feature = "cpu")]
192    fn detect_host_param_changes(&mut self) {
193        let regions = &self.interaction.knob_regions;
194        if regions.len() != self.last_painted_values.len() {
195            // Region set changed (e.g. after a layout rebuild). Force
196            // a repaint and re-sync on the next paint.
197            self.request_repaint();
198            return;
199        }
200        for (i, region) in regions.iter().enumerate() {
201            if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
202                self.request_repaint();
203                return;
204            }
205        }
206    }
207
208    /// Snapshot the regions' normalized values for the next frame's
209    /// automation detection. Called after each render. Only used by
210    /// the cpu blit path.
211    #[cfg(feature = "cpu")]
212    fn stash_painted_values(&mut self) {
213        let regions = &self.interaction.knob_regions;
214        // Resize-then-overwrite reuses the existing allocation
215        // unchanged when the region count is steady (the common
216        // case - knob layouts only change on
217        // `interaction.build_regions`). The previous
218        // clear-then-extend form pumped through the iterator path
219        // every frame even when the length didn't change.
220        self.last_painted_values.resize(regions.len(), 0.0);
221        for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
222            *slot = region.normalized_value;
223        }
224    }
225
226    /// Poll the layout's meters and flag a repaint when any value
227    /// moved since the last frame. Meters are display-only values the
228    /// audio thread reports through `PluginContext::get_meter`; they
229    /// don't flow through `detect_host_param_changes` (which only
230    /// inspects knob param regions), so without this the CPU gate would
231    /// freeze the meter until an unrelated repaint trigger (a knob drag,
232    /// host param churn) happened to fire. The gpu path repaints every
233    /// frame and skips this entirely.
234    #[cfg(feature = "cpu")]
235    #[allow(clippy::float_cmp)]
236    fn detect_meter_changes(&mut self) {
237        if self.meter_ids.is_empty() {
238            return;
239        }
240        let Some(ctx) = self.context.as_ref() else {
241            return;
242        };
243        let current: Vec<f32> = self.meter_ids.iter().map(|&id| ctx.get_meter(id)).collect();
244        if current != self.last_meter_values {
245            self.last_meter_values = current;
246            self.request_repaint();
247        }
248    }
249
250    pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
251        Self::with_layout_inner(params, Layout::Rows(layout))
252    }
253
254    pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
255        Self::with_layout_inner(params, layout)
256    }
257
258    pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
259        Self::with_layout_inner(params, Layout::Grid(layout))
260    }
261
262    fn with_layout_inner(params: Arc<P>, layout: Layout) -> Self {
263        #[cfg(feature = "cpu")]
264        let meter_ids = collect_meter_ids(&layout);
265        Self {
266            params,
267            layout,
268            theme: Theme::dark(),
269            #[cfg(feature = "cpu")]
270            backend: None,
271            interaction: InteractionState::default(),
272            context: None,
273            #[cfg(feature = "cpu")]
274            window: None,
275            #[cfg(feature = "cpu")]
276            blit_backend: None,
277            needs_repaint: Arc::new(AtomicBool::new(false)),
278            #[cfg(feature = "cpu")]
279            last_painted_values: Vec::new(),
280            #[cfg(feature = "cpu")]
281            scale: EditorScale::new(crate::backing_scale()),
282            #[cfg(feature = "cpu")]
283            use_system_scale: false,
284            #[cfg(feature = "cpu")]
285            host_scale_set: false,
286            #[cfg(feature = "cpu")]
287            meter_ids,
288            #[cfg(feature = "cpu")]
289            last_meter_values: Vec::new(),
290            #[cfg(feature = "cpu")]
291            pending_size: Arc::new(AtomicU64::new(0)),
292        }
293    }
294
295    #[must_use]
296    pub fn with_theme(mut self, theme: Theme) -> Self {
297        self.theme = theme;
298        self
299    }
300
301    /// Render the full UI to the internal CPU pixel buffer.
302    ///
303    /// Only available when the `cpu` feature is on. In `gpu`-only
304    /// mode, render through [`Self::render_to`] with a
305    /// `truce_gpu::WgpuBackend` instead.
306    ///
307    /// # Panics
308    ///
309    /// Panics if the lazy `CpuBackend::new` allocation fails (out of
310    /// memory or zero dimensions). The backend is allocated on first
311    /// render - subsequent calls reuse it.
312    #[cfg(feature = "cpu")]
313    pub fn render(&mut self) {
314        let (w, h) = (self.layout.width(), self.layout.height());
315        let scale = self.scale.get_f32();
316        let owned = self.build_snapshot_closures();
317        let snapshot = owned.as_snapshot();
318        // `Pixmap::new` returns `None` for zero / unrepresentable
319        // physical dimensions, which can happen when a host probes
320        // `gui_get_size` against an unreasonable scale or when an
321        // edge-case `set_size` makes it through with extreme
322        // values. Previously this site unwrapped, which turned a
323        // recoverable rendering miss into a Rust panic that the
324        // VST3 `extern "C"` boundary couldn't catch - Cubase then
325        // hit it as an uncaught exception and aborted. Skip the
326        // frame instead; the next `on_frame` tick will retry once
327        // dimensions settle.
328        let backend = if let Some(ref mut b) = self.backend {
329            b
330        } else {
331            let Some(b) = CpuBackend::new(w, h, scale) else {
332                log::warn!("CpuBackend allocation failed for {w}x{h} @ {scale}x; skipping frame");
333                return;
334            };
335            self.backend.insert(b)
336        };
337        render_widgets_impl(
338            &self.layout,
339            &self.theme,
340            &mut self.interaction,
341            &snapshot,
342            backend,
343        );
344    }
345
346    /// Build owned boxed closures from `self.context` / `self.params` that
347    /// back a `ParamSnapshot`. Each closure clones the `Arc<P>` or the
348    /// `PluginContext`, so `EditorSnapshotClosures` is `'static` and safe
349    /// to hold across a borrow of `&mut self.interaction`. Delegates to
350    /// the shared `render_core` impl so the iOS editor doesn't have to
351    /// duplicate the (~100-line) closure scaffolding.
352    fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
353        build_snapshot_closures_impl(&self.params, self.context.as_ref())
354    }
355
356    /// Apply a single `ParamEdit` returned by `interaction::dispatch`.
357    fn apply_edit(&self, edit: ParamEdit) {
358        match edit {
359            ParamEdit::Begin { id } => {
360                if let Some(ref ctx) = self.context {
361                    ctx.begin_edit(id);
362                }
363            }
364            ParamEdit::Set { id, normalized } => {
365                self.params.set_normalized(id, f64::from(normalized));
366                if let Some(ref ctx) = self.context {
367                    ctx.set_param(id, f64::from(normalized));
368                }
369                self.request_repaint();
370            }
371            ParamEdit::End { id } => {
372                if let Some(ref ctx) = self.context {
373                    ctx.end_edit(id);
374                }
375            }
376        }
377    }
378
379    /// Feed a batch of input events through `interaction::dispatch` and
380    /// apply the resulting param edits. Flags a repaint when hover,
381    /// dropdown-open state, or any param moved.
382    ///
383    /// Typically callers build the events by running each baseview
384    /// event through [`interaction::BaseviewTranslator`] and batching
385    /// the non-`None` results.
386    pub fn dispatch_events(&mut self, events: &[InputEvent]) {
387        let hover_before = self.interaction.hover_idx;
388        let dd_before = self.interaction.dropdown_is_open();
389        let owned = self.build_snapshot_closures();
390        let snapshot = owned.as_snapshot();
391        let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
392        let had_edits = !edits.is_empty();
393        for e in edits {
394            self.apply_edit(e);
395        }
396        // Anything that changes a pixel on screen flips the dirty
397        // bit: param edits (already covered by `apply_edit`), hover
398        // highlights moving between widgets, dropdown open/close
399        // transitions, and any event that explicitly requested a
400        // repaint (e.g. MouseLeave clearing hover state).
401        let explicit = self.interaction.take_repaint_request();
402        if had_edits
403            || explicit
404            || self.interaction.hover_idx != hover_before
405            || self.interaction.dropdown_is_open() != dd_before
406        {
407            self.request_repaint();
408        }
409    }
410
411    /// Get the raw pixel data after rendering (RGBA premultiplied).
412    /// Only available when the `cpu` feature is on.
413    #[cfg(feature = "cpu")]
414    #[must_use]
415    pub fn pixel_data(&self) -> Option<&[u8]> {
416        self.backend
417            .as_ref()
418            .map(super::backend_cpu::CpuBackend::data)
419    }
420
421    // --- Public API for external backends (truce-gpu) ---
422
423    /// Whether the editor has an active context.
424    #[must_use]
425    pub fn has_context(&self) -> bool {
426        self.context.is_some()
427    }
428
429    /// Take the editor context, leaving `None` in its place.
430    /// Used by hot-reload to preserve the context when swapping editors.
431    pub fn take_context(&mut self) -> Option<PluginContext> {
432        self.context.take()
433    }
434
435    /// Set the editor context (host callbacks) without opening the CPU view.
436    pub fn set_context(&mut self, context: PluginContext) {
437        self.context = Some(context);
438        match &self.layout {
439            Layout::Rows(pl) => self.interaction.build_regions(pl),
440            Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
441        }
442    }
443
444    /// Editor logical size (width, height in points). Inherent
445    /// method so it stays callable when the `Editor` trait impl is
446    /// cfg'd out in gpu-only builds.
447    #[must_use]
448    pub fn size(&self) -> (u32, u32) {
449        (self.layout.width(), self.layout.height())
450    }
451
452    /// Whether the editor supports host/user-driven resize. Inherent
453    /// for the same reason as [`Self::size`]: the GPU editor wraps this
454    /// type and delegates to it in gpu-only builds where the `Editor`
455    /// trait impl is cfg'd out.
456    #[must_use]
457    pub fn can_resize(&self) -> bool {
458        match &self.layout {
459            Layout::Grid(gl) => gl.resizable,
460            // `PluginLayout` (the older row-based layout) doesn't have a
461            // reflow path yet; pin it until that lands.
462            Layout::Rows(_) => false,
463        }
464    }
465
466    /// Minimum logical size in points. Inherent (see [`Self::size`]).
467    #[must_use]
468    pub fn min_size(&self) -> (u32, u32) {
469        match &self.layout {
470            // A non-resizable grid has exactly one size. The snapped
471            // probes only span a range for resizable grids (which set
472            // explicit min/max cells); probing a fixed grid reflows it
473            // and can report min > max, so pin both to the natural size
474            // like a `Rows` layout.
475            Layout::Grid(gl) if gl.resizable => gl.min_snapped_size(),
476            Layout::Grid(_) | Layout::Rows(_) => self.size(),
477        }
478    }
479
480    /// Maximum logical size in points. Inherent (see [`Self::size`]).
481    #[must_use]
482    pub fn max_size(&self) -> (u32, u32) {
483        match &self.layout {
484            Layout::Grid(gl) if gl.resizable => gl.max_snapped_size(),
485            Layout::Grid(_) | Layout::Rows(_) => self.size(),
486        }
487    }
488
489    /// Cell-step resize increment, or `None` when not resizable.
490    /// Inherent (see [`Self::size`]).
491    #[must_use]
492    pub fn size_increment(&self) -> Option<(u32, u32)> {
493        match &self.layout {
494            // Both axes snap on the same cell step. Only resizable
495            // grids advertise it; `Rows` layouts are pinned.
496            Layout::Grid(gl) if gl.resizable => {
497                let step = gl.resize_step();
498                Some((step, step))
499            }
500            _ => None,
501        }
502    }
503
504    /// Whether the standalone host may maximize the window. Inherent
505    /// (see [`Self::size`]) so the gpu-only `GpuEditor` wrapper can
506    /// reach it when this `Editor` impl is cfg'd out. Sourced from the
507    /// grid's `.maximizable()` (default `false`); `Rows` layouts are
508    /// fixed-size and never maximizable, and the value is moot there
509    /// anyway since `can_resize` is `false`.
510    #[must_use]
511    pub fn can_maximize(&self) -> bool {
512        match &self.layout {
513            Layout::Grid(gl) => gl.maximizable,
514            Layout::Rows(_) => false,
515        }
516    }
517
518    /// Snap a requested logical size to whole cells, reflow the grid,
519    /// and post the result for the next frame. Returns `true` when
520    /// accepted. Inherent (see [`Self::size`]).
521    pub fn set_size(&mut self, width: u32, height: u32) -> bool {
522        if width == 0 || height == 0 || !self.can_resize() {
523            return false;
524        }
525        let Layout::Grid(ref mut gl) = self.layout else {
526            return false;
527        };
528        // Snap each axis to a whole cell step independently:
529        // width drives the column count (auto-flow widgets reflow,
530        // explicit widgets stay put), height drives the row count
531        // (purely a bookkeeping value `compute_size` uses to grow
532        // the grid past the bottommost widget with empty trailing
533        // space). The wider snap *then* the taller snap so the
534        // final cached `(width, height)` includes both axes.
535        gl.refit_cols(width);
536        let (new_w, new_h) = gl.refit_rows(height);
537        // The CPU backend's `BuiltinWindowHandler` reads `pending_size`
538        // to drive its surface/window resize. The GPU wrapper instead
539        // polls `size()` each frame, so the cell only exists (and only
540        // needs writing) in cpu builds; the reflow above is the part
541        // both paths share.
542        #[cfg(feature = "cpu")]
543        self.pending_size.store(
544            (u64::from(new_w) << 32) | u64::from(new_h),
545            Ordering::Release,
546        );
547        #[cfg(not(feature = "cpu"))]
548        let _ = (new_w, new_h);
549        // Flip the dirty bit so a quiescent editor (no automation,
550        // no UI edits) still wakes up the `on_frame` repaint gate
551        // and picks up the new size on the next tick.
552        self.request_repaint();
553        true
554    }
555
556    /// Notify the widget tree that plugin state was restored
557    /// (preset recall, undo, session load). Inherent for the same
558    /// reason as [`Self::size`] above.
559    pub fn state_changed(&mut self) {
560        self.request_repaint();
561    }
562
563    /// Render all widgets to an external `RenderBackend`.
564    ///
565    /// Used by `truce-gpu` to draw through the GPU backend instead of
566    /// the internal CPU backend.
567    pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
568        update_interaction(self);
569        let owned = self.build_snapshot_closures();
570        let snapshot = owned.as_snapshot();
571        render_widgets_impl(
572            &self.layout,
573            &self.theme,
574            &mut self.interaction,
575            &snapshot,
576            backend,
577        );
578    }
579}
580
581/// Test-only ergonomic wrappers. Production callers go through
582/// `dispatch_events` (usually with events synthesized by
583/// [`crate::interaction::BaseviewTranslator`]).
584#[cfg(test)]
585impl<P: Params + 'static> BuiltinEditor<P> {
586    fn on_mouse_down(&mut self, x: f32, y: f32) {
587        self.dispatch_events(&[InputEvent::MouseDown {
588            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
589            x,
590            y,
591            button: crate::interaction::MouseButton::Left,
592        }]);
593    }
594
595    fn on_mouse_up(&mut self, x: f32, y: f32) {
596        self.dispatch_events(&[InputEvent::MouseUp {
597            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
598            x,
599            y,
600            button: crate::interaction::MouseButton::Left,
601        }]);
602    }
603
604    fn on_mouse_moved(&mut self, x: f32, y: f32) {
605        self.dispatch_events(&[InputEvent::MouseMove {
606            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
607            x,
608            y,
609        }]);
610    }
611}
612
613// ---------------------------------------------------------------------------
614// C callbacks - thin wrappers that cast the context pointer back to &mut Self
615// ---------------------------------------------------------------------------
616
617/// Update interaction regions and live param values.
618///
619/// Takes `&mut BuiltinEditor<P>` so the borrow checker enforces
620/// non-aliasing - the function only touches Rust references and is
621/// fully safe.
622pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
623    match &editor.layout {
624        Layout::Rows(pl) => {
625            editor.interaction.build_regions(pl);
626            let mut flat_idx = 0usize;
627            for row in &pl.rows {
628                for knob_def in &row.knobs {
629                    if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
630                        region.widget_type = resolve_widget_type(
631                            knob_def.widget,
632                            knob_def.param_id,
633                            &*editor.params,
634                        );
635                    }
636                    flat_idx += 1;
637                }
638            }
639        }
640        Layout::Grid(gl) => {
641            editor.interaction.build_regions_grid(gl);
642            for (idx, gw) in gl.widgets.iter().enumerate() {
643                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
644                    region.widget_type =
645                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
646                }
647            }
648        }
649    }
650    for region in &mut editor.interaction.knob_regions {
651        if let Some(ref ctx) = editor.context {
652            // Resolves through `PluginContextReadF32` - bridge's `f64` narrows inside.
653            region.normalized_value = ctx.get_param(region.param_id);
654        } else {
655            region.normalized_value =
656                f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
657        }
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Baseview WindowHandler - drives the CPU render loop
663// ---------------------------------------------------------------------------
664//
665// On macOS + AAX: blits via CoreGraphics (CGImage → CALayer) to avoid Metal
666// autorelease crashes with multiple editor windows.
667// Otherwise: blits via wgpu fullscreen triangle.
668//
669// The whole section (window handler + Editor trait impl below) is
670// gated behind the `cpu` feature. In `gpu`-only mode the editor is
671// provided by `GpuEditor` (which wraps `BuiltinEditor::render_to`
672// through `truce_gpu::WgpuBackend`) and these wgpu-blit details
673// drop out of the compile.
674
675/// Build the blit backend around a surface pump (see
676/// `truce_gpu::pump`). GPU init runs on the pump - off the host's GUI
677/// thread on Windows, where a stalled driver used to freeze the DAW
678/// at editor open - and [`BlitParts`] is adopted lazily via
679/// [`BlitBackend::parts_mut`]. Returns `None` when the pump can't
680/// spawn at all (blank but harmless editor).
681#[cfg(feature = "cpu")]
682fn create_wgpu_backend(
683    window: &mut baseview::Window,
684    phys_w: u32,
685    phys_h: u32,
686) -> Option<BlitBackend> {
687    // The panic flag is unused (no device-loss rebuild in this
688    // handler); a dead pump just leaves the editor blank.
689    let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
690    let pump = unsafe {
691        truce_gpu::pump::SurfacePump::spawn(
692            window,
693            &device_lost,
694            Box::new(move |_, adapter, surface| {
695                // `downlevel_defaults` caps `max_texture_dimension_2d` at 2048
696                // - on Retina (2x), that means the editor can't physically exceed
697                // 1024 logical points per axis before `surface.configure` panics
698                // with a validation error. Use the adapter's actual limits so a
699                // resizable layout (e.g. the GUI zoo) can grow to its declared
700                // `max_cols` / `max_rows` envelope without tripping the cap, then
701                // belt-and-braces clamp resize requests in `BlitBackend::resize`.
702                let adapter_limits = adapter.limits();
703                let max_texture_dim = adapter_limits.max_texture_dimension_2d;
704                let (device, queue) =
705                    pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
706                        label: Some("truce-gui"),
707                        required_features: wgpu::Features::empty(),
708                        required_limits: adapter_limits,
709                        experimental_features: wgpu::ExperimentalFeatures::default(),
710                        memory_hints: wgpu::MemoryHints::Performance,
711                        trace: wgpu::Trace::Off,
712                    }))
713                    .ok()?;
714
715                let caps = surface.get_capabilities(adapter);
716                let format = caps
717                    .formats
718                    .iter()
719                    .find(|f| f.is_srgb())
720                    .copied()
721                    .unwrap_or(caps.formats[0]);
722
723                // Same belt-and-braces clamp as `BlitBackend::resize` applies on
724                // subsequent reconfigures: a host could open the editor at a
725                // logical * DPI size that already exceeds `max_texture_dim`
726                // (e.g. a fixed-size editor on a 3x display whose physical
727                // dimensions are over the device cap).
728                let init_w = phys_w.clamp(1, max_texture_dim);
729                let init_h = phys_h.clamp(1, max_texture_dim);
730                let surface_config = wgpu::SurfaceConfiguration {
731                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
732                    format,
733                    width: init_w,
734                    height: init_h,
735                    // Windows: a Fifo (AutoVsync) present blocks when the
736                    // child-window swapchain backs up, freezing the host
737                    // (REAPER) when it lands on the GUI thread and risking
738                    // a GPU-watchdog (TDR) hang. Non-blocking present
739                    // there; elsewhere keeps vsync.
740                    #[cfg(target_os = "windows")]
741                    present_mode: wgpu::PresentMode::AutoNoVsync,
742                    #[cfg(not(target_os = "windows"))]
743                    present_mode: wgpu::PresentMode::AutoVsync,
744                    desired_maximum_frame_latency: 2,
745                    alpha_mode: wgpu::CompositeAlphaMode::Auto,
746                    view_formats: vec![],
747                };
748
749                // Blit texture matches the CPU pixmap, which is sized at
750                // physical pixels (see CpuBackend's scale handling). With texture
751                // and surface at the same physical size, the full-screen-triangle
752                // blit samples 1:1 - no stretch, no Retina blur.
753                let blit = crate::blit::BlitPipeline::new(&device, format, init_w, init_h);
754
755                let parts = BlitParts {
756                    blit,
757                    surface_config: surface_config.clone(),
758                    queue,
759                    device: device.clone(),
760                    max_texture_dim,
761                };
762                Some((parts, device, surface_config))
763            }),
764        )
765    }?;
766    Some(BlitBackend {
767        client: pump.client(),
768        parts: None,
769        pump,
770    })
771}
772
773/// The pump's init product: everything the GUI thread needs to encode
774/// the blit. Field-declaration order doubles as the implicit drop
775/// order - children before parent: per-pipeline GPU resources, then
776/// queue, then device. (The surface itself lives with the pump and
777/// drops with it.)
778#[cfg(feature = "cpu")]
779struct BlitParts {
780    blit: crate::blit::BlitPipeline,
781    /// Local bookkeeping copy; the authoritative configure happens on
782    /// the pump.
783    surface_config: wgpu::SurfaceConfiguration,
784    queue: wgpu::Queue,
785    device: wgpu::Device,
786    /// Adapter-reported `max_texture_dimension_2d`. `resize` clamps
787    /// each axis against this before reconfiguring so a host- or
788    /// DPI-driven resize past the device's texture cap can't trip a
789    /// wgpu validation panic (which unwinds out of the editor on the
790    /// host's UI thread and aborts the standalone / the DAW).
791    max_texture_dim: u32,
792}
793
794/// The blit pipeline plus the surface pump that owns its swapchain.
795/// `parts` stays `None` until the pump finishes GPU init (immediately
796/// on macOS / Linux, where init runs inline).
797#[cfg(feature = "cpu")]
798struct BlitBackend {
799    client: truce_gpu::pump::PumpClient,
800    parts: Option<BlitParts>,
801    pump: truce_gpu::pump::SurfacePump<BlitParts>,
802}
803
804#[cfg(feature = "cpu")]
805impl BlitBackend {
806    /// The pump's init product, adopting it if it just landed. `None`
807    /// while GPU init is still running (or after it failed).
808    fn parts_mut(&mut self) -> Option<&mut BlitParts> {
809        if self.parts.is_none()
810            && let Some(parts) = self.pump.take_init()
811        {
812            self.parts = Some(parts);
813        }
814        self.parts.as_mut()
815    }
816
817    /// Reconfigure the wgpu surface and blit texture for a new physical
818    /// size. Used when `Editor::set_scale_factor` reports a host-driven
819    /// DPI change - the logical editor size doesn't change, but the
820    /// physical pixmap and surface need to grow / shrink to match.
821    fn resize(&mut self, phys_w: u32, phys_h: u32) {
822        let client = self.client.clone();
823        let Some(parts) = self.parts_mut() else {
824            return;
825        };
826        let phys_w = phys_w.clamp(1, parts.max_texture_dim);
827        let phys_h = phys_h.clamp(1, parts.max_texture_dim);
828        parts.surface_config.width = phys_w;
829        parts.surface_config.height = phys_h;
830        client.resize(phys_w, phys_h);
831        parts.blit.resize(&parts.device, phys_w, phys_h);
832    }
833
834    /// Reconfigure only the swapchain surface to a new physical size,
835    /// leaving the blit texture (the CPU pixmap source) untouched.
836    ///
837    /// The surface must track the window's *real* physical extent so it
838    /// always covers it. That extent is set by the WM (X11, now
839    /// cell-snapped via resize-increment hints) or the host, and is not
840    /// bit-identical to `to_physical_px(logical, scale)` - sizing the
841    /// surface from the logical value instead leaves the window's
842    /// trailing edge showing whatever is behind it. The blit draws the
843    /// texture at native size on a pixel-snapped centre, so a surface a
844    /// few px larger than the texture letterboxes that difference to
845    /// black (no stretch, no garbage) rather than rescaling. Called from
846    /// the `Resized` handler, where the window's actual physical size is
847    /// authoritative.
848    fn configure_surface(&mut self, phys_w: u32, phys_h: u32) {
849        let client = self.client.clone();
850        let Some(parts) = self.parts_mut() else {
851            return;
852        };
853        let phys_w = phys_w.clamp(1, parts.max_texture_dim);
854        let phys_h = phys_h.clamp(1, parts.max_texture_dim);
855        if parts.surface_config.width == phys_w && parts.surface_config.height == phys_h {
856            return;
857        }
858        parts.surface_config.width = phys_w;
859        parts.surface_config.height = phys_h;
860        client.resize(phys_w, phys_h);
861    }
862}
863
864/// Shared ownership of the blit backend between `BuiltinEditor` and the
865/// `BuiltinWindowHandler` baseview hands us. Sharing lets the editor
866/// drop the wgpu surface *before* it asks baseview to close the
867/// `NSView`. Important on AAX where interleaving Metal teardown with
868/// baseview's close sequence inside Pro Tools' outer autorelease pool
869/// leaves stale refs in DFW container views.
870#[cfg(feature = "cpu")]
871type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
872
873#[cfg(feature = "cpu")]
874struct BuiltinWindowHandler<P: Params> {
875    /// Raw pointer to the `BuiltinEditor` owned by the host. Valid only
876    /// while `backend.lock()` returns `Some(_)`. `BuiltinEditor::close`
877    /// takes the inner `Option<BlitBackend>` (atomically through this
878    /// mutex) before returning, and the host can only drop the editor
879    /// after `close()` returns - so any frame that holds the lock and
880    /// finds the inner option `Some` is guaranteed the editor is still
881    /// alive. The lock acquire is the synchronization point that keeps
882    /// an in-flight `on_frame` from dereferencing this pointer after
883    /// the host dropped the editor while baseview's render thread still
884    /// had a callback queued. Only accessed from the GUI thread.
885    editor: *mut BuiltinEditor<P>,
886    backend: SharedBackend,
887    /// Canonical baseview → `InputEvent` translator. Handles cursor
888    /// tracking, double-click synthesis, and line→pixel scroll
889    /// conversion once for everyone.
890    translator: crate::interaction::BaseviewTranslator,
891    /// Paces paints to the compositor's measured consumption rate so
892    /// per-tick repaints (meters) can't park the host's GUI thread in
893    /// the swapchain acquire - see [`crate::PaintPacer`].
894    pacer: crate::platform::PaintPacer,
895    /// Last scale we built the CPU pixmap + wgpu surface against.
896    /// `on_frame` reads `editor.scale.get()` (via the raw ptr deref
897    /// it already does) and compares; on divergence it rebuilds the
898    /// pixmap and reconfigures the surface. Unlike egui / iced /
899    /// slint we don't need a separate `EditorScale` clone on the
900    /// handler - the editor is reachable through the same ptr that
901    /// guards the lifecycle, so reading `editor.scale` is the
902    /// canonical access path.
903    last_applied_scale: f32,
904    /// Enforces min/max/aspect on host resizes that bypassed the
905    /// format's negotiation hooks (Linux hosts resizing the embed
906    /// window directly).
907    resize_corrector: ResizeCorrector,
908}
909
910// SAFETY: The raw pointer is only accessed from the GUI thread.
911// baseview requires Send for WindowHandler.
912#[cfg(feature = "cpu")]
913unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
914
915#[cfg(feature = "cpu")]
916impl<P: Params + 'static> BuiltinWindowHandler<P> {
917    fn on_frame_inner(&mut self, window: &mut baseview::Window) {
918        // Lock the shared backend cell *before* deref'ing `self.editor`.
919        // `BuiltinEditor::close` calls `drop(guard.take())` on the same
920        // mutex before returning; the host then drops the editor. So
921        // either we observe `Some(_)` here (close hasn't taken it yet,
922        // editor still alive) or we observe `None` and return without
923        // touching `self.editor`. Either way the deref below is sound.
924        let Ok(mut guard) = self.backend.lock() else {
925            return;
926        };
927        if guard.is_none() {
928            // Editor already dropped the backend in its close path.
929            // Nothing to do - baseview will tear us down next.
930            return;
931        }
932
933        let editor = unsafe { &mut *self.editor };
934
935        // Pick up host-driven `set_size` requests posted to the
936        // shared `pending_size` cell since the last frame. The
937        // editor's `set_size` has already snapped to a whole
938        // column count and reflowed the grid via
939        // `GridLayout::refit_cols`; here we rebuild the CPU pixmap
940        // at the new logical size, reconfigure the wgpu blit
941        // surface to the new physical extent, refresh the
942        // interaction-region cache against the post-reflow widget
943        // layout, and resize the baseview window itself so the
944        // host's outer container follows. Same handoff pattern the
945        // egui / iced / slint editors use.
946        let pending = editor.pending_size.swap(0, Ordering::Acquire);
947        if pending != 0 {
948            #[allow(clippy::cast_possible_truncation)]
949            let new_w = (pending >> 32) as u32;
950            #[allow(clippy::cast_possible_truncation)]
951            let new_h = (pending & 0xFFFF_FFFF) as u32;
952            if new_w > 0 && new_h > 0 {
953                let scale = editor.scale.get();
954                let scale_f32 = editor.scale.get_f32();
955                let phys_w = crate::platform::to_physical_px(new_w, scale);
956                let phys_h = crate::platform::to_physical_px(new_h, scale);
957                editor.backend = CpuBackend::new(new_w, new_h, scale_f32);
958                if let Some(backend) = guard.as_mut() {
959                    backend.resize(phys_w, phys_h);
960                }
961                match &editor.layout {
962                    Layout::Rows(pl) => editor.interaction.build_regions(pl),
963                    Layout::Grid(gl) => editor.interaction.build_regions_grid(gl),
964                }
965                window.resize(baseview::Size::new(f64::from(new_w), f64::from(new_h)));
966                editor.request_repaint();
967            }
968        }
969
970        // Re-anchor on every frame so any host-driven drift of the
971        // child `NSView`'s origin gets corrected before the next
972        // paint. The wrapper installs `MinYMargin | MaxXMargin`
973        // (via `anchor_child_to_top`) on the child, which keeps the
974        // child top-anchored across *parent-driven* resizes - but
975        // both the editor resizing itself (via `window.resize`
976        // above) and the host reseating the child via its own
977        // `setFrameOrigin:` call (REAPER's plug-in framework does
978        // this) bypass AppKit's autoresize math. The result is a
979        // child whose top edge drifts off the host pane and the
980        // editor's GAIN header / knob row clip above the visible
981        // area while the canvas's empty trailing space + bottom
982        // labels show inside. Running every frame is cheap - it's
983        // one Cocoa frame query and a no-op short-circuit when
984        // already anchored - and is the cleanest place to assert
985        // the invariant the wrapper expects.
986        // Skip the whole frame while the editor isn't presentable:
987        // detached / occluded on macOS, host child window hidden /
988        // minimized on Windows (no-op on Linux).
989        {
990            use raw_window_handle::HasRawWindowHandle;
991            if crate::platform::should_skip_frame(window.raw_window_handle()) {
992                return;
993            }
994        }
995        #[cfg(target_os = "macos")]
996        {
997            use raw_window_handle::HasRawWindowHandle;
998            crate::platform::reanchor_to_superview_top(window.raw_window_handle());
999        }
1000
1001        // Pick up scale changes that landed in the shared cell since
1002        // the last frame - either from a host callback (CLAP
1003        // `set_scale`, VST3 `IPlugViewContentScaleSupport`) or from
1004        // the OS-driven `Resized` path writing through `info.scale()`.
1005        // Logical w×h is fixed when resize is disallowed; only the
1006        // logical→physical ratio moves through here.
1007        if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
1008            let (lw, lh) = editor.size();
1009            let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
1010            let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
1011            editor.backend = CpuBackend::new(lw, lh, cur_scale);
1012            if let Some(backend) = guard.as_mut() {
1013                backend.resize(phys_w, phys_h);
1014            }
1015            editor.request_repaint();
1016        }
1017
1018        update_interaction(editor);
1019        // Pick up host automation / preset recall that changed params
1020        // without going through the UI: flips the dirty bit so the
1021        // normal gate below still has the chance to short-circuit when
1022        // truly nothing moved.
1023        editor.detect_host_param_changes();
1024        editor.detect_meter_changes();
1025        // Compositor pacing veto - before `take_needs_repaint` so the
1026        // dirty bit survives the held ticks and the deferred paint
1027        // still happens. Windows skips the veto: the pump pre-acquires
1028        // frames off-thread and `try_take_frame` returning `None`
1029        // already paces paints to the compositor, so holding here only
1030        // adds latency.
1031        if cfg!(not(target_os = "windows")) && self.pacer.should_hold() {
1032            return;
1033        }
1034        if !editor.take_needs_repaint() {
1035            return;
1036        }
1037        // Get the pump's frame BEFORE rasterizing or uploading. During
1038        // resize churn no frame is available (the pump is busy
1039        // reconfiguring); skipping everything here saves the wasted
1040        // CPU raster and keeps queue work (texture upload, submit) off
1041        // the GUI thread while the pump's configure is in flight -
1042        // those contend on wgpu's internal locks. On Windows the take
1043        // never blocks (pump pre-acquires); elsewhere it acquires
1044        // inline with the usual stale-surface recovery.
1045        let client = {
1046            let backend = guard
1047                .as_mut()
1048                .expect("guard was checked Some above and the lock is still held");
1049            if backend.parts_mut().is_none() {
1050                // GPU init still pending on the pump (Windows) or
1051                // failed; re-arm the dirty bit so the first ready
1052                // frame paints instead of waiting for the next edit.
1053                editor.request_repaint();
1054                return;
1055            }
1056            backend.client.clone()
1057        };
1058        let frame = client.try_take_frame();
1059        self.pacer.record_acquire(client.last_acquire_wait());
1060        let Some(frame) = frame else {
1061            // Windows: the pump is still acquiring - re-arm the
1062            // dirty bit so the paint lands when the frame is
1063            // ready. Elsewhere `None` is a transient Timeout /
1064            // Occluded; skip and let the next edit repaint.
1065            #[cfg(target_os = "windows")]
1066            editor.request_repaint();
1067            return;
1068        };
1069        editor.render();
1070        editor.stash_painted_values();
1071
1072        if let Some(pixels) = editor.pixel_data() {
1073            let backend = guard
1074                .as_mut()
1075                .expect("guard was checked Some above and the lock is still held");
1076            let Some(parts) = backend.parts_mut() else {
1077                client.discard(frame);
1078                editor.request_repaint();
1079                return;
1080            };
1081            let BlitParts {
1082                device,
1083                queue,
1084                surface_config,
1085                blit,
1086                ..
1087            } = parts;
1088            // A resize raced the acquire: the frame is at the old
1089            // extent; discard it (the pump reconfigures + reacquires).
1090            if (frame.texture.width(), frame.texture.height())
1091                != (surface_config.width, surface_config.height)
1092            {
1093                client.discard(frame);
1094                editor.request_repaint();
1095                return;
1096            }
1097            blit.update(queue, pixels);
1098            let view = frame
1099                .texture
1100                .create_view(&wgpu::TextureViewDescriptor::default());
1101            let mut encoder =
1102                device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1103            blit.render(
1104                queue,
1105                &mut encoder,
1106                &view,
1107                surface_config.width,
1108                surface_config.height,
1109            );
1110            queue.submit(std::iter::once(encoder.finish()));
1111            client.present(frame);
1112        } else {
1113            client.discard(frame);
1114        }
1115    }
1116
1117    // Mirrors the by-value `WindowHandler::on_event` signature it's
1118    // called from; pedantic clippy can't tell that the `match event`
1119    // arms only bind `Copy` fields.
1120    #[allow(clippy::needless_pass_by_value)]
1121    fn on_event_inner(
1122        &mut self,
1123        window: &mut baseview::Window,
1124        event: baseview::Event,
1125    ) -> baseview::EventStatus {
1126        // `window` is only read on Windows (focus-on-click below);
1127        // discard explicitly on other platforms so the lint stays quiet.
1128        #[cfg(not(target_os = "windows"))]
1129        let _ = &window;
1130
1131        if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
1132            button: baseview::MouseButton::Left,
1133            ..
1134        }) = &event
1135        {
1136            // WS_CHILD plugin windows don't receive WM_KEYDOWN
1137            // until focused; baseview doesn't SetFocus on click,
1138            // so we do it here. Without this, text-edit widgets
1139            // never see keystrokes (the DAW keeps eating them for
1140            // transport shortcuts).
1141            #[cfg(target_os = "windows")]
1142            {
1143                if !window.has_focus() {
1144                    window.focus();
1145                }
1146            }
1147        }
1148
1149        // Lock-then-check-then-deref pattern, same as `on_frame` -
1150        // the backend cell is the synchronization point with
1151        // `BuiltinEditor::close`. If the cell is `None`, the editor
1152        // pointer is no longer guaranteed valid and we must not deref.
1153        let Ok(mut guard) = self.backend.lock() else {
1154            return baseview::EventStatus::Ignored;
1155        };
1156        if guard.is_none() {
1157            return baseview::EventStatus::Ignored;
1158        }
1159
1160        match event {
1161            baseview::Event::Mouse(_) => {
1162                let Some(input) = self.translator.translate(&event) else {
1163                    return baseview::EventStatus::Ignored;
1164                };
1165                let editor = unsafe { &mut *self.editor };
1166                editor.dispatch_events(&[input]);
1167                baseview::EventStatus::Captured
1168            }
1169            baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1170                // Two things can flow through `Resized`:
1171                //  - A backing-scale change (monitor-boundary drag,
1172                //    host calling `set_scale_factor`): logical w×h is
1173                //    invariant, only `info.scale()` matters.
1174                //  - A logical resize via the autoresize cascade
1175                //    (host grows the parent NSView with our child
1176                //    tagged `WidthSizable | HeightSizable`, or the
1177                //    standalone window grows around us). For
1178                //    resizable editors we route the new bounds into
1179                //    `set_size` so the grid reflows; fixed-size
1180                //    editors stay pinned.
1181                let editor = unsafe { &mut *self.editor };
1182                editor.scale.set(info.scale());
1183                crate::platform::note_linux_scale_factor(info.scale());
1184                let phys = info.physical_size();
1185                if editor.can_resize() {
1186                    let scale = info.scale();
1187                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1188                    let (lw, lh) = if scale > 0.0 {
1189                        (
1190                            (f64::from(phys.width) / scale).round() as u32,
1191                            (f64::from(phys.height) / scale).round() as u32,
1192                        )
1193                    } else {
1194                        (phys.width, phys.height)
1195                    };
1196                    if lw > 0 && lh > 0 {
1197                        // A host that resized the embed window directly
1198                        // never ran the format's constraint preflight -
1199                        // fit here and push the corrected size back.
1200                        let ((fw, fh), correct) = self.resize_corrector.fit(
1201                            lw,
1202                            lh,
1203                            editor.min_size(),
1204                            editor.max_size(),
1205                            editor.aspect_ratio(),
1206                        );
1207                        if (fw, fh) != editor.size() {
1208                            editor.set_size(fw, fh);
1209                        }
1210                        if let Some((rw, rh)) = correct {
1211                            // On Linux, hosts that bypass size negotiation
1212                            // (Bitwig) ignore this request and react by
1213                            // *growing* the embed window - a resize loop.
1214                            // Clamp the content (and counter-resize our child)
1215                            // but never ask the host to resize its frame.
1216                            // mac/windows honor it (and negotiate via
1217                            // `checkSizeConstraint`) anyway.
1218                            #[cfg(not(target_os = "linux"))]
1219                            if let Some(ctx) = editor.context.as_ref() {
1220                                let _ = ctx.request_resize(rw, rh);
1221                            }
1222                            #[cfg(target_os = "linux")]
1223                            let _ = (rw, rh);
1224                        }
1225                    }
1226                }
1227                // Keep the swapchain covering the window's *actual*
1228                // physical size. The WM (X11 resize-increment snap) or
1229                // host sets that size, and it isn't bit-identical to the
1230                // `to_physical_px(logical)` the `on_frame` resize paths
1231                // configure the surface to - so without this the trailing
1232                // edge of the window shows whatever is behind it. Driving
1233                // the surface from the authoritative `info.physical_size()`
1234                // here closes that gap; the blit letterboxes any ≤few-px
1235                // difference to black on a pixel-snapped centre (no
1236                // stretch), so a fixed editor under a host that wobbles
1237                // the embed size ±1px stays crisp instead of shimmering.
1238                if phys.width > 0
1239                    && phys.height > 0
1240                    && let Some(backend) = guard.as_mut()
1241                {
1242                    backend.configure_surface(phys.width, phys.height);
1243                }
1244                // Always repaint on a `Resized`, even when the logical
1245                // size is unchanged. Our own `set_size` -> `on_frame`
1246                // resize is asynchronous on X11: `on_frame` reconfigures
1247                // the surface and presents one frame *before* the
1248                // `ConfigureNotify` actually grows the child window, then
1249                // clears the dirty bit. The trailing `Resized` that
1250                // reports the now-grown window carries a logical size
1251                // that already matches `editor.size()`, so without this
1252                // the gate short-circuits and the freshly exposed region
1253                // is never painted - it shows whatever was behind the
1254                // window until the next unrelated repaint.
1255                editor.request_repaint();
1256                baseview::EventStatus::Ignored
1257            }
1258            _ => baseview::EventStatus::Ignored,
1259        }
1260    }
1261}
1262
1263#[cfg(feature = "cpu")]
1264impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
1265    fn on_frame(&mut self, window: &mut baseview::Window) {
1266        // Catch panics at the FFI boundary. baseview calls us through
1267        // an `extern "C-unwind"` AppKit override; an unwinding Rust
1268        // panic becomes an ObjC exception and `NSApplication run`
1269        // rethrows it, terminating the host. Swallow the panic and
1270        // log it so the host stays alive.
1271        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1272            self.on_frame_inner(window);
1273        }));
1274        if let Err(e) = result {
1275            let msg = if let Some(s) = e.downcast_ref::<&str>() {
1276                s.to_string()
1277            } else if let Some(s) = e.downcast_ref::<String>() {
1278                s.clone()
1279            } else {
1280                "unknown panic".to_string()
1281            };
1282            log::error!("BuiltinWindowHandler::on_frame panic swallowed: {msg}");
1283        }
1284    }
1285
1286    fn on_event(
1287        &mut self,
1288        window: &mut baseview::Window,
1289        event: baseview::Event,
1290    ) -> baseview::EventStatus {
1291        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1292            self.on_event_inner(window, event)
1293        }));
1294        result.unwrap_or_else(|e| {
1295            let msg = if let Some(s) = e.downcast_ref::<&str>() {
1296                s.to_string()
1297            } else if let Some(s) = e.downcast_ref::<String>() {
1298                s.clone()
1299            } else {
1300                "unknown panic".to_string()
1301            };
1302            log::error!("BuiltinWindowHandler::on_event panic swallowed: {msg}");
1303            baseview::EventStatus::Ignored
1304        })
1305    }
1306}
1307
1308// ---------------------------------------------------------------------------
1309// Editor trait implementation
1310// ---------------------------------------------------------------------------
1311
1312/// Resolve widget type: explicit override > auto-detect from param range.
1313fn resolve_widget_type<P: Params>(
1314    widget: Option<crate::layout::WidgetKind>,
1315    param_id: u32,
1316    params: &P,
1317) -> widgets::WidgetType {
1318    match widget {
1319        Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1320        Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1321        Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1322        Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1323        Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1324        Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1325        None => {
1326            let param_info = params
1327                .param_infos()
1328                .iter()
1329                .find(|i| i.id == param_id)
1330                .copied();
1331            match param_info.as_ref().map(|i| &i.range) {
1332                Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1333                    widgets::WidgetType::Toggle
1334                }
1335                Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1336                _ => widgets::WidgetType::Knob,
1337            }
1338        }
1339    }
1340}
1341
1342#[cfg(feature = "cpu")]
1343impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1344    fn size(&self) -> (u32, u32) {
1345        (self.layout.width(), self.layout.height())
1346    }
1347
1348    fn state_changed(&mut self) {
1349        // Preset recall / undo / session load: params moved without
1350        // going through the UI, so force the next idle tick to repaint.
1351        self.request_repaint();
1352    }
1353
1354    // These forward to the inherent methods of the same name (inherent
1355    // methods win method resolution, so `self.foo()` is not recursive).
1356    // The logic lives inherently so the gpu-only `GpuEditor` wrapper can
1357    // reach it when this `Editor` impl is cfg'd out.
1358    fn can_resize(&self) -> bool {
1359        self.can_resize()
1360    }
1361
1362    fn can_maximize(&self) -> bool {
1363        self.can_maximize()
1364    }
1365
1366    fn min_size(&self) -> (u32, u32) {
1367        self.min_size()
1368    }
1369
1370    fn max_size(&self) -> (u32, u32) {
1371        self.max_size()
1372    }
1373
1374    fn size_increment(&self) -> Option<(u32, u32)> {
1375        self.size_increment()
1376    }
1377
1378    fn set_size(&mut self, width: u32, height: u32) -> bool {
1379        self.set_size(width, height)
1380    }
1381
1382    fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1383        let (w, h) = self.size();
1384        // Drop any stale `set_size` that fired before this `open()`
1385        // so the next frame doesn't immediately re-resize the
1386        // freshly-built window to a previous request.
1387        self.pending_size.store(0, Ordering::Relaxed);
1388        // Refresh the shared scale from the parent window - on macOS
1389        // this is the live `[NSWindow backingScaleFactor]`, on
1390        // Windows the per-monitor DPI from the parent HWND. Any
1391        // `set_scale_factor` the host issues after open will overwrite
1392        // through the same shared cell.
1393        // Pick the baseview scale policy. On Linux an embedded plugin
1394        // follows the host's scale (default 1.0) rather than the desktop
1395        // Xft.dpi, which a non-DPI-aware host (Bitwig) doesn't share; the
1396        // standalone and every macOS/Windows path keep SystemScaleFactor.
1397        let scale_policy = if let Some(s) = crate::platform::editor_window_scale(
1398            self.use_system_scale,
1399            self.host_scale_set,
1400            self.scale.get(),
1401        ) {
1402            self.scale.set(s);
1403            baseview::WindowScalePolicy::ScaleFactor(s)
1404        } else {
1405            self.scale
1406                .set(crate::platform::query_backing_scale(&parent));
1407            baseview::WindowScalePolicy::SystemScaleFactor
1408        };
1409        let scale = self.scale.get();
1410        let scale_f32 = self.scale.get_f32();
1411        self.backend = CpuBackend::new(w, h, scale_f32);
1412        self.context = Some(context);
1413
1414        // Build interaction regions
1415        match &self.layout {
1416            Layout::Rows(pl) => self.interaction.build_regions(pl),
1417            Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1418        }
1419
1420        // Render initial frame and flag dirty so the first `on_frame`
1421        // blit also runs (the construction default is `false` because a
1422        // not-yet-opened editor has nothing to paint to).
1423        self.render();
1424        self.request_repaint();
1425
1426        let (lw, lh) = (f64::from(w), f64::from(h));
1427        let phys_w = crate::platform::to_physical_px(w, scale);
1428        let phys_h = crate::platform::to_physical_px(h, scale);
1429
1430        let options = baseview::WindowOpenOptions {
1431            title: String::from("truce"),
1432            size: baseview::Size::new(lw, lh),
1433            scale: scale_policy,
1434        };
1435
1436        let parent_wrapper = crate::platform::ParentWindow(parent);
1437        let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1438
1439        // Shared backend cell: the editor keeps one Arc and baseview's
1440        // window handler gets the other. At close time the editor
1441        // takes the inner Option and drops it *before* asking baseview
1442        // to tear down the NSView.
1443        let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1444        self.blit_backend = Some(shared_backend.clone());
1445        let shared_for_handler = shared_backend;
1446
1447        let window = baseview::Window::open_parented(
1448            &parent_wrapper,
1449            options,
1450            move |window: &mut baseview::Window| {
1451                let backend = create_wgpu_backend(window, phys_w, phys_h);
1452
1453                // Render + present an initial frame synchronously, before
1454                // baseview shows the window. Without this, the window briefly
1455                // displays whatever garbage is in the surface buffer until the
1456                // first `on_frame` tick - especially noticeable on VST2
1457                // (Windows), where `effEditOpen` creates and shows the window
1458                // in one call. On Windows the pump is still initializing here
1459                // (`parts_mut` is `None`), so this paint is skipped and the
1460                // dirty bit set at `open()` covers the first ready frame.
1461                let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1462                editor.render();
1463                let mut backend = backend;
1464                if let Some(pixels) = editor.pixel_data()
1465                    && let Some(backend) = backend.as_mut()
1466                {
1467                    let client = backend.client.clone();
1468                    if let Some(parts) = backend.parts_mut() {
1469                        let BlitParts {
1470                            device,
1471                            queue,
1472                            surface_config,
1473                            blit,
1474                            ..
1475                        } = parts;
1476                        blit.update(queue, pixels);
1477                        if let Some(frame) = client.try_take_frame() {
1478                            let view = frame
1479                                .texture
1480                                .create_view(&wgpu::TextureViewDescriptor::default());
1481                            let mut encoder =
1482                                device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1483                                    label: None,
1484                                });
1485                            blit.render(
1486                                queue,
1487                                &mut encoder,
1488                                &view,
1489                                surface_config.width,
1490                                surface_config.height,
1491                            );
1492                            queue.submit(std::iter::once(encoder.finish()));
1493                            client.present(frame);
1494                        }
1495                    }
1496                }
1497
1498                // Publish the backend into the shared cell. If the
1499                // editor has already been asked to close (very
1500                // unlikely race - only if close fires before baseview
1501                // calls our build closure), the None-check on the
1502                // mutex side will simply replace Some(None) → Some
1503                // and everything drops at the usual time.
1504                if let Ok(mut guard) = shared_for_handler.lock() {
1505                    *guard = backend;
1506                }
1507
1508                BuiltinWindowHandler {
1509                    editor: editor_addr as *mut BuiltinEditor<P>,
1510                    backend: shared_for_handler.clone(),
1511                    translator: crate::interaction::BaseviewTranslator::default(),
1512                    last_applied_scale: scale_f32,
1513                    pacer: crate::platform::PaintPacer::default(),
1514                    resize_corrector: ResizeCorrector::default(),
1515                }
1516            },
1517        );
1518
1519        self.window = Some(window);
1520    }
1521
1522    fn set_scale_factor(&mut self, factor: f64) {
1523        // Write to the shared cell; the baseview handler picks up the
1524        // change on its next frame and rebuilds the CPU pixmap +
1525        // reconfigures the wgpu surface. The trait's default no-op
1526        // would silently swallow host scale changes here.
1527        self.host_scale_set = true;
1528        self.scale.set(factor);
1529    }
1530
1531    fn set_uses_system_scale(&mut self, yes: bool) {
1532        self.use_system_scale = yes;
1533    }
1534
1535    fn close(&mut self) {
1536        // On macOS, wrap the teardown in an autoreleasepool so
1537        // anything baseview / wgpu / AppKit autoreleases during the
1538        // view's cleanup drains here rather than escaping into the
1539        // host's outer pool. AAX / Pro Tools is the canonical host
1540        // that walks back through residual responders before the
1541        // pool drains, surfacing use-after-free crashes.
1542        #[cfg(target_os = "macos")]
1543        let pool = unsafe {
1544            unsafe extern "C" {
1545                fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1546            }
1547            objc_autoreleasePoolPush()
1548        };
1549
1550        // Drop the wgpu surface (CAMetalLayer, MTLDevice, command
1551        // queue, etc.) before asking baseview to release the NSView.
1552        // Keeps the Metal teardown order deterministic. The destructure
1553        // makes the drop order explicit rather than depending on
1554        // `BlitPipeline`'s field-declaration order. Order: per-pipeline
1555        // GPU resources first (textures, bind groups, sampler), then
1556        // the pump (which owns and releases the surface / swap chain /
1557        // CAMetalLayer), then queue, then device last - children
1558        // before parent.
1559        if let Some(shared) = self.blit_backend.take()
1560            && let Ok(mut guard) = shared.lock()
1561            && let Some(backend) = guard.take()
1562        {
1563            let BlitBackend {
1564                client,
1565                parts,
1566                pump,
1567            } = backend;
1568            if let Some(BlitParts {
1569                blit,
1570                surface_config,
1571                queue,
1572                device,
1573                max_texture_dim: _,
1574            }) = parts
1575            {
1576                drop(surface_config);
1577                drop(blit);
1578                drop(client);
1579                drop(pump);
1580                drop(queue);
1581                drop(device);
1582            } else {
1583                drop(client);
1584                drop(pump);
1585            }
1586        }
1587
1588        if let Some(mut window) = self.window.take() {
1589            window.close();
1590        }
1591        self.context = None;
1592        self.backend = None;
1593
1594        #[cfg(target_os = "macos")]
1595        unsafe {
1596            unsafe extern "C" {
1597                fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1598            }
1599            objc_autoreleasePoolPop(pool);
1600        }
1601    }
1602
1603    fn idle(&mut self) {
1604        // baseview drives `on_frame` via its internal timer; idle is
1605        // only meaningful for the headless/standalone case where the
1606        // caller wants a render cycle to pull pixel data out.
1607        if self.window.is_none() {
1608            self.render();
1609        }
1610    }
1611
1612    fn screenshot(
1613        &mut self,
1614        _params: Arc<dyn truce_params::Params>,
1615    ) -> Option<(Vec<u8>, u32, u32)> {
1616        // Headless render of the widget tree into a fresh
1617        // `CpuBackend` at the live content scale. Mirrors
1618        // `GpuEditor::screenshot`'s shape: same `render_to` call
1619        // path, same physical-size rounding so reference PNGs baked
1620        // on either backend match dimensions exactly. Used by
1621        // `truce_test::assert_screenshot::<P>()`.
1622        let (lw, lh) = self.size();
1623        let scale = self.scale.get_f32();
1624        let mut backend = CpuBackend::new(lw, lh, scale)?;
1625        self.render_to(&mut backend);
1626        let pixels = backend.data().to_vec();
1627        let (phys_w, phys_h) = (backend.width(), backend.height());
1628        Some((pixels, phys_w, phys_h))
1629    }
1630}
1631
1632#[cfg(feature = "cpu")]
1633impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1634    fn drop(&mut self) {
1635        // The baseview `WindowHandle` does not cancel the macOS frame
1636        // timer when it drops, and the NSView keeps its own strong
1637        // `Rc<WindowState>`, so the timer keeps firing `on_frame`
1638        // against the handler's raw `*mut BuiltinEditor`. If the host
1639        // drops us without calling `Editor::close` first, that pointer
1640        // dangles the moment our fields (`scale`, the shared backend)
1641        // are freed - the next tick deref'd freed memory and crashes in
1642        // `EditorScale::take_change`. Run the same teardown here so the
1643        // timer is always cancelled before our fields go away; it is
1644        // idempotent via the `Option::take`s, so a prior `close` makes
1645        // this a no-op.
1646        Editor::close(self);
1647    }
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652    // Layout-coordinate assertions compare stored anchor values for
1653    // bit-exact equality (no arithmetic between them).
1654    #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1655
1656    use super::*;
1657    use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1658    use crate::widgets::WidgetType;
1659    use std::sync::Arc;
1660    use std::sync::atomic::{AtomicU64, Ordering};
1661    use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1662
1663    // -- Mock Params with one enum param (4 options) and one float --
1664
1665    struct TestParams {
1666        values: [AtomicU64; 2],
1667    }
1668
1669    impl TestParams {
1670        fn new() -> Self {
1671            Self {
1672                values: [
1673                    AtomicU64::new(0.0f64.to_bits()),
1674                    AtomicU64::new(0.0f64.to_bits()),
1675                ],
1676            }
1677        }
1678    }
1679
1680    impl truce_params::__private::Sealed for TestParams {}
1681    impl Params for TestParams {
1682        fn param_infos(&self) -> Vec<ParamInfo> {
1683            vec![
1684                ParamInfo {
1685                    id: 0,
1686                    name: "Mode",
1687                    short_name: "Mode",
1688                    group: "",
1689                    range: ParamRange::Enum { count: 4 },
1690                    default_plain: 0.0,
1691                    flags: ParamFlags::AUTOMATABLE,
1692                    unit: ParamUnit::None,
1693                    kind: ParamValueKind::Enum,
1694                    midi_map: None,
1695                    midi_channel: None,
1696                },
1697                ParamInfo {
1698                    id: 1,
1699                    name: "Gain",
1700                    short_name: "Gain",
1701                    group: "",
1702                    range: ParamRange::Linear { min: 0.0, max: 1.0 },
1703                    default_plain: 0.5,
1704                    flags: ParamFlags::AUTOMATABLE,
1705                    unit: ParamUnit::None,
1706                    kind: ParamValueKind::Float,
1707                    midi_map: None,
1708                    midi_channel: None,
1709                },
1710            ]
1711        }
1712
1713        fn count(&self) -> usize {
1714            2
1715        }
1716
1717        fn get_normalized(&self, id: u32) -> Option<f64> {
1718            self.values
1719                .get(id as usize)
1720                .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1721        }
1722
1723        fn set_normalized(&self, id: u32, value: f64) {
1724            if let Some(v) = self.values.get(id as usize) {
1725                v.store(value.to_bits(), Ordering::Relaxed);
1726            }
1727        }
1728
1729        fn get_plain(&self, id: u32) -> Option<f64> {
1730            let norm = self.get_normalized(id)?;
1731            let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1732            Some(info.range.denormalize(norm))
1733        }
1734
1735        fn set_plain(&self, id: u32, value: f64) {
1736            if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1737                self.set_normalized(id, info.range.normalize(value));
1738            }
1739        }
1740
1741        fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1742            Some(format!("{value:.0}"))
1743        }
1744
1745        fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1746            None
1747        }
1748        fn snap_smoothers(&self) {}
1749        fn set_sample_rate(&self, _: f64) {}
1750
1751        fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1752            let ids = vec![0, 1];
1753            let vals: Vec<f64> = ids
1754                .iter()
1755                .map(|&id| self.get_plain(id).unwrap_or(0.0))
1756                .collect();
1757            (ids, vals)
1758        }
1759
1760        fn restore_values(&self, values: &[(u32, f64)]) {
1761            for &(id, val) in values {
1762                self.set_plain(id, val);
1763            }
1764        }
1765    }
1766
1767    impl Default for TestParams {
1768        fn default() -> Self {
1769            Self::new()
1770        }
1771    }
1772
1773    // -- Helpers --
1774
1775    /// Build a `BuiltinEditor` with a dropdown at position 0 and a knob at position 1.
1776    fn make_editor() -> BuiltinEditor<TestParams> {
1777        let params = Arc::new(TestParams::new());
1778        let layout = GridLayout::build(vec![widgets(vec![
1779            GridWidget::dropdown(0u32, "Mode"),
1780            GridWidget::knob(1u32, "Gain"),
1781        ])]);
1782        let mut editor = BuiltinEditor::new_grid(params, layout);
1783        // Build interaction regions (normally done in open/render)
1784        if let Layout::Grid(ref gl) = editor.layout {
1785            editor.interaction.build_regions_grid(gl);
1786            for (idx, gw) in gl.widgets.iter().enumerate() {
1787                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1788                    region.widget_type =
1789                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1790                }
1791            }
1792        }
1793        // Render once to populate dropdown_anchor_y
1794        editor.render();
1795        editor
1796    }
1797
1798    /// Build an editor with section breaks to test anchor stability.
1799    fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1800        let params = Arc::new(TestParams::new());
1801        let layout = GridLayout::build(vec![
1802            section(
1803                "SECTION A",
1804                vec![
1805                    GridWidget::knob(1u32, "Gain"),
1806                    GridWidget::knob(1u32, "Gain 2"),
1807                ],
1808            ),
1809            section(
1810                "SECTION B",
1811                vec![
1812                    GridWidget::dropdown(0u32, "Mode"),
1813                    GridWidget::knob(1u32, "Gain 3"),
1814                ],
1815            ),
1816        ]);
1817        let mut editor = BuiltinEditor::new_grid(params, layout);
1818        if let Layout::Grid(ref gl) = editor.layout {
1819            editor.interaction.build_regions_grid(gl);
1820            for (idx, gw) in gl.widgets.iter().enumerate() {
1821                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1822                    region.widget_type =
1823                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1824                }
1825            }
1826        }
1827        editor.render();
1828        editor
1829    }
1830
1831    /// Find the center of the first dropdown widget's region.
1832    fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1833        let region = editor
1834            .interaction
1835            .knob_regions
1836            .iter()
1837            .find(|r| r.widget_type == WidgetType::Dropdown)
1838            .expect("no dropdown in layout");
1839        (region.x + region.w / 2.0, region.y + region.h / 2.0)
1840    }
1841
1842    // -- Tests: dropdown close-on-reclick --
1843
1844    #[test]
1845    fn dropdown_click_opens() {
1846        let mut editor = make_editor();
1847        let (dx, dy) = dropdown_center(&editor);
1848
1849        editor.on_mouse_down(dx, dy);
1850        assert!(editor.interaction.dropdown_is_open());
1851    }
1852
1853    #[test]
1854    fn dropdown_click_toggles_closed() {
1855        let mut editor = make_editor();
1856        let (dx, dy) = dropdown_center(&editor);
1857
1858        // Open
1859        editor.on_mouse_down(dx, dy);
1860        editor.on_mouse_up(dx, dy);
1861        assert!(editor.interaction.dropdown_is_open());
1862
1863        // Click same button again - should close, not reopen
1864        editor.on_mouse_down(dx, dy);
1865        assert!(!editor.interaction.dropdown_is_open());
1866    }
1867
1868    #[test]
1869    fn dropdown_click_outside_closes() {
1870        let mut editor = make_editor();
1871        let (dx, dy) = dropdown_center(&editor);
1872
1873        editor.on_mouse_down(dx, dy);
1874        editor.on_mouse_up(dx, dy);
1875        assert!(editor.interaction.dropdown_is_open());
1876
1877        // Click far away
1878        editor.on_mouse_down(0.0, 0.0);
1879        assert!(!editor.interaction.dropdown_is_open());
1880    }
1881
1882    #[test]
1883    fn dropdown_click_option_selects_and_closes() {
1884        let mut editor = make_editor();
1885        let (dx, dy) = dropdown_center(&editor);
1886
1887        editor.on_mouse_down(dx, dy);
1888        editor.on_mouse_up(dx, dy);
1889        assert!(editor.interaction.dropdown_is_open());
1890
1891        // Click the second option (index 1) inside the popup
1892        let dd = editor.interaction.dropdown.as_ref().unwrap();
1893        let (px, py, _, _) = dd.popup_rect;
1894        let item_h = 18.0f32;
1895        let padding = 4.0f32;
1896        let option_y = py + padding + item_h + item_h / 2.0; // middle of second item
1897
1898        // Touch model: down then up at the same point commits the
1899        // option under the release point. (Down alone starts a
1900        // popup-drag - the up handler decides commit-vs-scroll.)
1901        editor.on_mouse_down(px + 10.0, option_y);
1902        editor.on_mouse_up(px + 10.0, option_y);
1903
1904        assert!(!editor.interaction.dropdown_is_open());
1905        // Enum{count:4} → step_count=3 → 4 options. Index 1 → norm = 1/3
1906        let norm = editor.params.get_normalized(0).unwrap();
1907        let expected = 1.0 / 3.0;
1908        assert!(
1909            (norm - expected).abs() < 0.01,
1910            "expected {expected:.4}, got {norm}"
1911        );
1912    }
1913
1914    // -- Tests: dropdown anchor positioning --
1915
1916    #[test]
1917    fn dropdown_anchor_set_after_render() {
1918        let editor = make_editor();
1919        let region = editor
1920            .interaction
1921            .knob_regions
1922            .iter()
1923            .find(|r| r.widget_type == WidgetType::Dropdown)
1924            .unwrap();
1925
1926        // Anchor should be within the widget region (below y, above y+h)
1927        assert!(
1928            region.dropdown_anchor_y > region.y,
1929            "anchor {} should be below region.y {}",
1930            region.dropdown_anchor_y,
1931            region.y
1932        );
1933        assert!(
1934            region.dropdown_anchor_y < region.y + region.h,
1935            "anchor {} should be above region bottom {}",
1936            region.dropdown_anchor_y,
1937            region.y + region.h
1938        );
1939    }
1940
1941    #[test]
1942    fn dropdown_popup_uses_anchor() {
1943        let mut editor = make_editor();
1944        let (dx, dy) = dropdown_center(&editor);
1945
1946        editor.on_mouse_down(dx, dy);
1947        editor.on_mouse_up(dx, dy);
1948
1949        let dd = editor.interaction.dropdown.as_ref().unwrap();
1950        let region = &editor.interaction.knob_regions[dd.region_idx];
1951
1952        // popup_y must equal the stored anchor - popup always
1953        // anchors directly below the button (scrolls on tight
1954        // editors rather than relocating).
1955        assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1956    }
1957
1958    #[test]
1959    fn dropdown_anchor_survives_idle_rebuild() {
1960        // Regression: the CPU `on_frame` runs `update_interaction`
1961        // (which rebuilds regions) every frame, but gates `render`
1962        // behind a repaint check. On an idle frame the rebuild ran
1963        // without a following render, resetting `dropdown_anchor_y`
1964        // to 0 and stranding the next dropdown popup at the top of
1965        // the window. The rebuild must preserve the anchor.
1966        let mut editor = make_editor();
1967
1968        // Simulate an idle frame: regions rebuilt, no render after.
1969        update_interaction(&mut editor);
1970
1971        let (dx, dy) = dropdown_center(&editor);
1972        editor.on_mouse_down(dx, dy);
1973        editor.on_mouse_up(dx, dy);
1974
1975        let dd = editor.interaction.dropdown.as_ref().unwrap();
1976        let region = &editor.interaction.knob_regions[dd.region_idx];
1977        assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1978        assert!(
1979            dd.popup_rect.1 > region.y,
1980            "popup_y {} fell back to the window top instead of anchoring below the button",
1981            dd.popup_rect.1
1982        );
1983    }
1984
1985    #[test]
1986    fn dropdown_anchor_gap_stable_with_sections() {
1987        let editor_plain = make_editor();
1988        let editor_sections = make_editor_with_sections();
1989
1990        let r_plain = editor_plain
1991            .interaction
1992            .knob_regions
1993            .iter()
1994            .find(|r| r.widget_type == WidgetType::Dropdown)
1995            .unwrap();
1996        let r_sections = editor_sections
1997            .interaction
1998            .knob_regions
1999            .iter()
2000            .find(|r| r.widget_type == WidgetType::Dropdown)
2001            .unwrap();
2002
2003        // The gap from widget vertical center to anchor should be identical
2004        // regardless of section offsets shifting the absolute Y position.
2005        let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
2006        let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
2007        assert!(
2008            (gap_plain - gap_sections).abs() < 0.1,
2009            "gap_plain={gap_plain}, gap_sections={gap_sections}"
2010        );
2011    }
2012
2013    // -- Mock Params with a large enum (20 options) for overflow/scroll tests --
2014
2015    struct ManyOptionParams {
2016        values: [AtomicU64; 2],
2017    }
2018
2019    impl ManyOptionParams {
2020        fn new() -> Self {
2021            Self {
2022                values: [
2023                    AtomicU64::new(0.0f64.to_bits()),
2024                    AtomicU64::new(0.0f64.to_bits()),
2025                ],
2026            }
2027        }
2028    }
2029
2030    impl truce_params::__private::Sealed for ManyOptionParams {}
2031    impl Params for ManyOptionParams {
2032        fn param_infos(&self) -> Vec<ParamInfo> {
2033            vec![
2034                ParamInfo {
2035                    id: 0,
2036                    name: "Note",
2037                    short_name: "Note",
2038                    group: "",
2039                    range: ParamRange::Enum { count: 20 },
2040                    default_plain: 0.0,
2041                    flags: ParamFlags::AUTOMATABLE,
2042                    unit: ParamUnit::None,
2043                    kind: ParamValueKind::Enum,
2044                    midi_map: None,
2045                    midi_channel: None,
2046                },
2047                ParamInfo {
2048                    id: 1,
2049                    name: "Gain",
2050                    short_name: "Gain",
2051                    group: "",
2052                    range: ParamRange::Linear { min: 0.0, max: 1.0 },
2053                    default_plain: 0.5,
2054                    flags: ParamFlags::AUTOMATABLE,
2055                    unit: ParamUnit::None,
2056                    kind: ParamValueKind::Float,
2057                    midi_map: None,
2058                    midi_channel: None,
2059                },
2060            ]
2061        }
2062
2063        fn count(&self) -> usize {
2064            2
2065        }
2066
2067        fn get_normalized(&self, id: u32) -> Option<f64> {
2068            self.values
2069                .get(id as usize)
2070                .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
2071        }
2072
2073        fn set_normalized(&self, id: u32, value: f64) {
2074            if let Some(v) = self.values.get(id as usize) {
2075                v.store(value.to_bits(), Ordering::Relaxed);
2076            }
2077        }
2078
2079        fn get_plain(&self, id: u32) -> Option<f64> {
2080            let norm = self.get_normalized(id)?;
2081            let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
2082            Some(info.range.denormalize(norm))
2083        }
2084
2085        fn set_plain(&self, id: u32, value: f64) {
2086            if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
2087                self.set_normalized(id, info.range.normalize(value));
2088            }
2089        }
2090
2091        fn format_value(&self, _id: u32, value: f64) -> Option<String> {
2092            Some(format!("{value:.0}"))
2093        }
2094
2095        fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
2096            None
2097        }
2098        fn snap_smoothers(&self) {}
2099        fn set_sample_rate(&self, _: f64) {}
2100
2101        fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
2102            let ids = vec![0, 1];
2103            let vals: Vec<f64> = ids
2104                .iter()
2105                .map(|&id| self.get_plain(id).unwrap_or(0.0))
2106                .collect();
2107            (ids, vals)
2108        }
2109
2110        fn restore_values(&self, values: &[(u32, f64)]) {
2111            for &(id, val) in values {
2112                self.set_plain(id, val);
2113            }
2114        }
2115    }
2116
2117    impl Default for ManyOptionParams {
2118        fn default() -> Self {
2119            Self::new()
2120        }
2121    }
2122
2123    // -- Additional helpers --
2124
2125    /// Build an editor with a dropdown in the last row (near the window bottom).
2126    fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
2127        let params = Arc::new(TestParams::new());
2128        // 3 rows of 2, dropdown in the last row (row 2)
2129        let layout = GridLayout::build(vec![widgets(vec![
2130            GridWidget::knob(1u32, "K1"),
2131            GridWidget::knob(1u32, "K2"),
2132            GridWidget::knob(1u32, "K3"),
2133            GridWidget::knob(1u32, "K4"),
2134            GridWidget::dropdown(0u32, "Mode"),
2135            GridWidget::knob(1u32, "K5"),
2136        ])])
2137        .with_cols(2);
2138        let mut editor = BuiltinEditor::new_grid(params, layout);
2139        if let Layout::Grid(ref gl) = editor.layout {
2140            editor.interaction.build_regions_grid(gl);
2141            for (idx, gw) in gl.widgets.iter().enumerate() {
2142                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2143                    region.widget_type =
2144                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2145                }
2146            }
2147        }
2148        editor.render();
2149        editor
2150    }
2151
2152    /// Build an editor with two dropdowns side by side.
2153    fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
2154        let params = Arc::new(TestParams::new());
2155        let layout = GridLayout::build(vec![widgets(vec![
2156            GridWidget::dropdown(0u32, "Mode A"),
2157            GridWidget::dropdown(0u32, "Mode B"),
2158        ])]);
2159        let mut editor = BuiltinEditor::new_grid(params, layout);
2160        if let Layout::Grid(ref gl) = editor.layout {
2161            editor.interaction.build_regions_grid(gl);
2162            for (idx, gw) in gl.widgets.iter().enumerate() {
2163                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2164                    region.widget_type =
2165                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2166                }
2167            }
2168        }
2169        editor.render();
2170        editor
2171    }
2172
2173    /// Build an editor with a 20-option dropdown for scroll testing.
2174    fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
2175        let params = Arc::new(ManyOptionParams::new());
2176        let layout = GridLayout::build(vec![widgets(vec![
2177            GridWidget::dropdown(0u32, "Note"),
2178            GridWidget::knob(1u32, "Gain"),
2179        ])]);
2180        let mut editor = BuiltinEditor::new_grid(params, layout);
2181        if let Layout::Grid(ref gl) = editor.layout {
2182            editor.interaction.build_regions_grid(gl);
2183            for (idx, gw) in gl.widgets.iter().enumerate() {
2184                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2185                    region.widget_type =
2186                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2187                }
2188            }
2189        }
2190        editor.render();
2191        editor
2192    }
2193
2194    fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
2195        let region = editor
2196            .interaction
2197            .knob_regions
2198            .iter()
2199            .find(|r| r.widget_type == WidgetType::Dropdown)
2200            .expect("no dropdown in layout");
2201        (region.x + region.w / 2.0, region.y + region.h / 2.0)
2202    }
2203
2204    // -- Tests: dropdown overflow/clipping --
2205
2206    #[test]
2207    fn dropdown_anchors_below_button_scrolls_when_tight() {
2208        let mut editor = make_editor_bottom_dropdown();
2209        let (dx, dy) = {
2210            let region = editor
2211                .interaction
2212                .knob_regions
2213                .iter()
2214                .find(|r| r.widget_type == WidgetType::Dropdown)
2215                .unwrap();
2216            (region.x + region.w / 2.0, region.y + region.h / 2.0)
2217        };
2218
2219        editor.on_mouse_down(dx, dy);
2220        editor.on_mouse_up(dx, dy);
2221        assert!(editor.interaction.dropdown_is_open());
2222
2223        let dd = editor.interaction.dropdown.as_ref().unwrap();
2224        let region = &editor.interaction.knob_regions[dd.region_idx];
2225        let (_, popup_y, _, popup_h) = dd.popup_rect;
2226        let window_h = editor.layout.height() as f32;
2227
2228        // Popup anchors at the button's bottom - never shifts up
2229        // and never flips above. If the full option list doesn't
2230        // fit between the anchor and the window bottom, the popup
2231        // scrolls instead of relocating away from the tap target.
2232        assert_eq!(
2233            popup_y, region.dropdown_anchor_y,
2234            "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2235        );
2236        // Popup never extends past the window bottom.
2237        assert!(
2238            popup_y + popup_h <= window_h + 1.0,
2239            "popup bottom {} exceeds window height {window_h}",
2240            popup_y + popup_h
2241        );
2242    }
2243
2244    #[test]
2245    fn dropdown_clamps_horizontal_near_right_edge() {
2246        let mut editor = make_editor_two_dropdowns();
2247        // The second dropdown is in column 1 (right side)
2248        let region = &editor.interaction.knob_regions[1];
2249        assert_eq!(region.widget_type, WidgetType::Dropdown);
2250        let dx = region.x + region.w / 2.0;
2251        let dy = region.y + region.h / 2.0;
2252
2253        editor.on_mouse_down(dx, dy);
2254        editor.on_mouse_up(dx, dy);
2255        assert!(editor.interaction.dropdown_is_open());
2256
2257        let dd = editor.interaction.dropdown.as_ref().unwrap();
2258        let (popup_x, _, popup_w, _) = dd.popup_rect;
2259        let window_w = editor.layout.width() as f32;
2260
2261        assert!(
2262            popup_x + popup_w <= window_w + 1.0,
2263            "popup right edge {} exceeds window width {window_w}",
2264            popup_x + popup_w
2265        );
2266        assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2267    }
2268
2269    #[test]
2270    fn dropdown_scroll_long_list() {
2271        let mut editor = make_editor_many_options();
2272        let (dx, dy) = dropdown_center_many(&editor);
2273
2274        editor.on_mouse_down(dx, dy);
2275        editor.on_mouse_up(dx, dy);
2276        assert!(editor.interaction.dropdown_is_open());
2277
2278        let dd = editor.interaction.dropdown.as_ref().unwrap();
2279        // 20-option enum → step_count = 19 → 19 options
2280        assert!(
2281            dd.options.len() > dd.visible_count,
2282            "expected scroll: {} options, {} visible",
2283            dd.options.len(),
2284            dd.visible_count
2285        );
2286        assert_eq!(dd.scroll_offset, 0);
2287    }
2288
2289    #[test]
2290    fn dropdown_scroll_clamps_to_bounds() {
2291        let mut editor = make_editor_many_options();
2292        let (dx, dy) = dropdown_center_many(&editor);
2293
2294        editor.on_mouse_down(dx, dy);
2295        editor.on_mouse_up(dx, dy);
2296
2297        // Scroll up past the top - should stay at 0
2298        editor.interaction.dropdown_scroll(-10);
2299        assert_eq!(
2300            editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2301            0
2302        );
2303
2304        // Scroll down past the bottom - should clamp
2305        editor.interaction.dropdown_scroll(1000);
2306        let dd = editor.interaction.dropdown.as_ref().unwrap();
2307        let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2308        assert_eq!(dd.scroll_offset, max_offset);
2309    }
2310
2311    #[test]
2312    fn dropdown_selected_item_visible_on_open() {
2313        let mut editor = make_editor_many_options();
2314        // Set the value to option 15 out of 19 (normalized = 15/18)
2315        editor.params.set_normalized(0, 15.0 / 18.0);
2316
2317        let (dx, dy) = dropdown_center_many(&editor);
2318        editor.on_mouse_down(dx, dy);
2319        editor.on_mouse_up(dx, dy);
2320
2321        let dd = editor.interaction.dropdown.as_ref().unwrap();
2322        let selected = dd.selected;
2323        // The selected item should be within the visible window
2324        assert!(
2325            selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2326            "selected={selected} not in visible range {}..{}",
2327            dd.scroll_offset,
2328            dd.scroll_offset + dd.visible_count
2329        );
2330    }
2331
2332    #[test]
2333    fn dropdown_scroll_then_select_correct_index() {
2334        let mut editor = make_editor_many_options();
2335        let (dx, dy) = dropdown_center_many(&editor);
2336
2337        editor.on_mouse_down(dx, dy);
2338        editor.on_mouse_up(dx, dy);
2339
2340        // Scroll down by 3
2341        editor.interaction.dropdown_scroll(3);
2342        assert_eq!(
2343            editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2344            3
2345        );
2346
2347        // Click the second visible item (local index 1 → absolute index 4)
2348        let dd = editor.interaction.dropdown.as_ref().unwrap();
2349        let (px, py, _, _) = dd.popup_rect;
2350        let item_h = 18.0f32;
2351        let padding = 4.0f32;
2352        let click_y = py + padding + item_h + item_h / 2.0; // middle of second visible item
2353
2354        editor.on_mouse_down(px + 10.0, click_y);
2355        editor.on_mouse_up(px + 10.0, click_y);
2356
2357        assert!(!editor.interaction.dropdown_is_open());
2358        // Absolute index = scroll_offset(3) + local(1) = 4
2359        // 20 options → norm = 4/19
2360        let norm = editor.params.get_normalized(0).unwrap();
2361        let expected = 4.0 / 19.0;
2362        assert!(
2363            (norm - expected).abs() < 0.01,
2364            "expected {expected:.4}, got {norm:.4}"
2365        );
2366    }
2367
2368    #[test]
2369    fn dropdown_click_different_dropdown_closes_first() {
2370        let mut editor = make_editor_two_dropdowns();
2371        let r0 = &editor.interaction.knob_regions[0];
2372        let r1 = &editor.interaction.knob_regions[1];
2373        let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2374        let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2375
2376        // Open dropdown A
2377        editor.on_mouse_down(ax, ay);
2378        editor.on_mouse_up(ax, ay);
2379        assert!(editor.interaction.dropdown_is_open());
2380        assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2381
2382        // Click dropdown B - should close A and open B
2383        editor.on_mouse_down(bx, by);
2384        editor.on_mouse_up(bx, by);
2385        assert!(editor.interaction.dropdown_is_open());
2386        assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2387    }
2388
2389    #[test]
2390    fn dropdown_hover_tracks_correct_option() {
2391        let mut editor = make_editor();
2392        let (dx, dy) = dropdown_center(&editor);
2393
2394        editor.on_mouse_down(dx, dy);
2395        editor.on_mouse_up(dx, dy);
2396
2397        let dd = editor.interaction.dropdown.as_ref().unwrap();
2398        let (px, py, pw, _) = dd.popup_rect;
2399        let item_h = 18.0f32;
2400        let padding = 4.0f32;
2401        let last_visible = dd.visible_count - 1;
2402
2403        // Hover over the last visible item
2404        let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2405        editor.on_mouse_moved(px + pw / 2.0, hover_y);
2406
2407        let dd = editor.interaction.dropdown.as_ref().unwrap();
2408        assert_eq!(
2409            dd.hover_option,
2410            Some(last_visible),
2411            "expected hover on last visible option"
2412        );
2413
2414        // Move outside the popup
2415        editor.on_mouse_moved(0.0, 0.0);
2416        let dd = editor.interaction.dropdown.as_ref().unwrap();
2417        assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2418    }
2419
2420    #[test]
2421    fn dropdown_popup_within_window_bounds() {
2422        // Verify popup never exceeds window in any direction
2423        let mut editor = make_editor();
2424        let (dx, dy) = dropdown_center(&editor);
2425
2426        editor.on_mouse_down(dx, dy);
2427        editor.on_mouse_up(dx, dy);
2428
2429        let dd = editor.interaction.dropdown.as_ref().unwrap();
2430        let (px, py, pw, ph) = dd.popup_rect;
2431        let window_w = editor.layout.width() as f32;
2432        let window_h = editor.layout.height() as f32;
2433
2434        assert!(px >= 0.0, "popup left edge {px} < 0");
2435        assert!(py >= 0.0, "popup top edge {py} < 0");
2436        assert!(
2437            px + pw <= window_w + 1.0,
2438            "popup right {} > window {window_w}",
2439            px + pw
2440        );
2441        assert!(
2442            py + ph <= window_h + 1.0,
2443            "popup bottom {} > window {window_h}",
2444            py + ph
2445        );
2446    }
2447}