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