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