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
8use std::ptr;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11
12use truce_core::Float;
13use truce_core::editor::{Editor, PluginContext, PluginContextReadF32, RawWindowHandle};
14use truce_params::Params;
15
16use crate::backend_cpu::CpuBackend;
17use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
18use crate::layout::{GridLayout, Layout, PluginLayout};
19use crate::platform::EditorScale;
20use crate::render::RenderBackend;
21use crate::render_core::{
22    EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
23    render_widgets as render_widgets_impl,
24};
25use crate::theme::Theme;
26use crate::widgets;
27
28/// Built-in editor that renders parameter widgets to a pixel buffer.
29///
30/// Uses the CPU backend (tiny-skia) for software rasterization. When
31/// `open()` is called, creates a baseview window and blits pixels via wgpu.
32pub struct BuiltinEditor<P: Params> {
33    params: Arc<P>,
34    layout: Layout,
35    theme: Theme,
36    backend: Option<CpuBackend>,
37    interaction: InteractionState,
38    context: Option<PluginContext>,
39    window: Option<baseview::WindowHandle>,
40    /// Weak-ish handle to the blit backend the window-handler
41    /// materializes. The editor keeps the canonical `Arc` and the
42    /// handler gets a clone. On close we take the `Option` out of
43    /// the inner mutex - dropping the wgpu Surface synchronously -
44    /// before asking baseview to tear the `NSView` down.
45    blit_backend: Option<SharedBackend>,
46    /// Set whenever something visible changes (param edited via the
47    /// UI, host-driven state reload, explicit `request_repaint` by
48    /// plugin code). `on_frame` clears it and only does the
49    /// rasterize + blit pass when it was true.
50    ///
51    /// Shared so `PluginContext::set_param` and `state_changed`
52    /// closures can flip it without touching editor internals.
53    needs_repaint: Arc<AtomicBool>,
54    /// Normalized values captured at the last render pass, in the
55    /// same order as `interaction.knob_regions`. Used to detect
56    /// host-driven param changes (automation, preset recall) - if any
57    /// live value drifts from the last-painted one, we force a
58    /// repaint even if the UI never received a direct edit.
59    last_painted_values: Vec<f32>,
60    /// Live content-scale factor (a [`crate::platform::EditorScale`]).
61    /// `set_scale_factor` (host) writes the cell; the baseview
62    /// handler holds a clone, compares against `last_applied_scale`
63    /// each frame, and rebuilds the CPU pixmap + reconfigures the
64    /// wgpu surface when the value diverges.
65    scale: EditorScale,
66}
67
68// SAFETY: `baseview::WindowHandle` holds a raw native window pointer
69// (HWND / NSView / X11 Window) and is not auto-`Send`. Hosts call
70// `Editor::open` / `idle` / `close` from a single dedicated GUI thread
71// - never concurrently and never from the audio thread - so the
72// handle is only ever touched on the thread that created it. The
73// `Editor` trait requires `Send` so the editor can live behind a
74// trait object; this impl asserts that the type doesn't escape its
75// thread in practice. All other fields (`Arc<P>`, `Layout`, `Theme`,
76// `Option<CpuBackend>`, etc.) are themselves `Send`.
77unsafe impl<P: Params> Send for BuiltinEditor<P> {}
78
79impl<P: Params + 'static> BuiltinEditor<P> {
80    /// Request a repaint on the next idle tick. Call this if plugin
81    /// code mutates display state outside the normal param or
82    /// `state_changed` pathways (uncommon). User interaction and
83    /// host automation already flag themselves dirty automatically.
84    pub fn request_repaint(&self) {
85        self.needs_repaint.store(true, Ordering::Release);
86    }
87
88    fn take_needs_repaint(&self) -> bool {
89        self.needs_repaint.swap(false, Ordering::AcqRel)
90    }
91
92    /// Compare the values just read by `update_interaction` (live from
93    /// the host / params Arc) against those captured at the last
94    /// render. A mismatch means an automation lane wrote a new value,
95    /// a preset was recalled, or some other off-UI state change
96    /// happened - force a repaint so the widget tracks it.
97    fn detect_host_param_changes(&mut self) {
98        let regions = &self.interaction.knob_regions;
99        if regions.len() != self.last_painted_values.len() {
100            // Region set changed (e.g. after a layout rebuild). Force
101            // a repaint and re-sync on the next paint.
102            self.request_repaint();
103            return;
104        }
105        for (i, region) in regions.iter().enumerate() {
106            if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
107                self.request_repaint();
108                return;
109            }
110        }
111    }
112
113    /// Snapshot the regions' normalized values for the next frame's
114    /// automation detection. Called after each render.
115    fn stash_painted_values(&mut self) {
116        let regions = &self.interaction.knob_regions;
117        // Resize-then-overwrite reuses the existing allocation
118        // unchanged when the region count is steady (the common
119        // case - knob layouts only change on
120        // `interaction.build_regions`). The previous
121        // clear-then-extend form pumped through the iterator path
122        // every frame even when the length didn't change.
123        self.last_painted_values.resize(regions.len(), 0.0);
124        for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
125            *slot = region.normalized_value;
126        }
127    }
128
129    pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
130        Self {
131            params,
132            layout: Layout::Rows(layout),
133            theme: Theme::dark(),
134            backend: None,
135            interaction: InteractionState::default(),
136            context: None,
137            window: None,
138            blit_backend: None,
139            needs_repaint: Arc::new(AtomicBool::new(false)),
140            last_painted_values: Vec::new(),
141            scale: EditorScale::new(crate::backing_scale()),
142        }
143    }
144
145    pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
146        Self {
147            params,
148            layout,
149            theme: Theme::dark(),
150            backend: None,
151            interaction: InteractionState::default(),
152            context: None,
153            window: None,
154            blit_backend: None,
155            needs_repaint: Arc::new(AtomicBool::new(false)),
156            last_painted_values: Vec::new(),
157            scale: EditorScale::new(crate::backing_scale()),
158        }
159    }
160
161    pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
162        Self {
163            params,
164            layout: Layout::Grid(layout),
165            theme: Theme::dark(),
166            backend: None,
167            interaction: InteractionState::default(),
168            context: None,
169            window: None,
170            blit_backend: None,
171            needs_repaint: Arc::new(AtomicBool::new(false)),
172            last_painted_values: Vec::new(),
173            scale: EditorScale::new(crate::backing_scale()),
174        }
175    }
176
177    #[must_use]
178    pub fn with_theme(mut self, theme: Theme) -> Self {
179        self.theme = theme;
180        self
181    }
182
183    /// Render the full UI to the internal CPU pixel buffer.
184    ///
185    /// # Panics
186    ///
187    /// Panics if the lazy `CpuBackend::new` allocation fails (out of
188    /// memory or zero dimensions). The backend is allocated on first
189    /// render - subsequent calls reuse it.
190    pub fn render(&mut self) {
191        let (w, h) = (self.layout.width(), self.layout.height());
192        let scale = self.scale.get_f32();
193        let owned = self.build_snapshot_closures();
194        let snapshot = owned.as_snapshot();
195        let backend = self
196            .backend
197            .get_or_insert_with(|| CpuBackend::new(w, h, scale).expect("Failed to create backend"));
198        render_widgets_impl(
199            &self.layout,
200            &self.theme,
201            &mut self.interaction,
202            &snapshot,
203            backend,
204        );
205    }
206
207    /// Build owned boxed closures from `self.context` / `self.params` that
208    /// back a `ParamSnapshot`. Each closure clones the `Arc<P>` or the
209    /// `PluginContext`, so `EditorSnapshotClosures` is `'static` and safe
210    /// to hold across a borrow of `&mut self.interaction`. Delegates to
211    /// the shared `render_core` impl so the iOS editor doesn't have to
212    /// duplicate the (~100-line) closure scaffolding.
213    fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
214        build_snapshot_closures_impl(&self.params, self.context.as_ref())
215    }
216
217    /// Apply a single `ParamEdit` returned by `interaction::dispatch`.
218    fn apply_edit(&self, edit: ParamEdit) {
219        match edit {
220            ParamEdit::Begin { id } => {
221                if let Some(ref ctx) = self.context {
222                    ctx.begin_edit(id);
223                }
224            }
225            ParamEdit::Set { id, normalized } => {
226                self.params.set_normalized(id, f64::from(normalized));
227                if let Some(ref ctx) = self.context {
228                    ctx.set_param(id, f64::from(normalized));
229                }
230                self.request_repaint();
231            }
232            ParamEdit::End { id } => {
233                if let Some(ref ctx) = self.context {
234                    ctx.end_edit(id);
235                }
236            }
237        }
238    }
239
240    /// Feed a batch of input events through `interaction::dispatch` and
241    /// apply the resulting param edits. Flags a repaint when hover,
242    /// dropdown-open state, or any param moved.
243    ///
244    /// Typically callers build the events by running each baseview
245    /// event through [`interaction::BaseviewTranslator`] and batching
246    /// the non-`None` results.
247    pub fn dispatch_events(&mut self, events: &[InputEvent]) {
248        let hover_before = self.interaction.hover_idx;
249        let dd_before = self.interaction.dropdown_is_open();
250        let owned = self.build_snapshot_closures();
251        let snapshot = owned.as_snapshot();
252        let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
253        let had_edits = !edits.is_empty();
254        for e in edits {
255            self.apply_edit(e);
256        }
257        // Anything that changes a pixel on screen flips the dirty
258        // bit: param edits (already covered by `apply_edit`), hover
259        // highlights moving between widgets, dropdown open/close
260        // transitions, and any event that explicitly requested a
261        // repaint (e.g. MouseLeave clearing hover state).
262        let explicit = self.interaction.take_repaint_request();
263        if had_edits
264            || explicit
265            || self.interaction.hover_idx != hover_before
266            || self.interaction.dropdown_is_open() != dd_before
267        {
268            self.request_repaint();
269        }
270    }
271
272    /// Get the raw pixel data after rendering (RGBA premultiplied).
273    #[must_use]
274    pub fn pixel_data(&self) -> Option<&[u8]> {
275        self.backend
276            .as_ref()
277            .map(super::backend_cpu::CpuBackend::data)
278    }
279
280    // --- Public API for external backends (truce-gpu) ---
281
282    /// Whether the editor has an active context.
283    #[must_use]
284    pub fn has_context(&self) -> bool {
285        self.context.is_some()
286    }
287
288    /// Take the editor context, leaving `None` in its place.
289    /// Used by hot-reload to preserve the context when swapping editors.
290    pub fn take_context(&mut self) -> Option<PluginContext> {
291        self.context.take()
292    }
293
294    /// Set the editor context (host callbacks) without opening the CPU view.
295    pub fn set_context(&mut self, context: PluginContext) {
296        self.context = Some(context);
297        match &self.layout {
298            Layout::Rows(pl) => self.interaction.build_regions(pl),
299            Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
300        }
301    }
302
303    /// Render all widgets to an external `RenderBackend`.
304    ///
305    /// Used by `truce-gpu` to draw through the GPU backend instead of
306    /// the internal CPU backend.
307    pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
308        update_interaction(self);
309        let owned = self.build_snapshot_closures();
310        let snapshot = owned.as_snapshot();
311        render_widgets_impl(
312            &self.layout,
313            &self.theme,
314            &mut self.interaction,
315            &snapshot,
316            backend,
317        );
318    }
319}
320
321/// Test-only ergonomic wrappers. Production callers go through
322/// `dispatch_events` (usually with events synthesized by
323/// [`crate::interaction::BaseviewTranslator`]).
324#[cfg(test)]
325impl<P: Params + 'static> BuiltinEditor<P> {
326    fn on_mouse_down(&mut self, x: f32, y: f32) {
327        self.dispatch_events(&[InputEvent::MouseDown {
328            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
329            x,
330            y,
331            button: crate::interaction::MouseButton::Left,
332        }]);
333    }
334
335    fn on_mouse_up(&mut self, x: f32, y: f32) {
336        self.dispatch_events(&[InputEvent::MouseUp {
337            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
338            x,
339            y,
340            button: crate::interaction::MouseButton::Left,
341        }]);
342    }
343
344    fn on_mouse_moved(&mut self, x: f32, y: f32) {
345        self.dispatch_events(&[InputEvent::MouseMove {
346            pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
347            x,
348            y,
349        }]);
350    }
351}
352
353// ---------------------------------------------------------------------------
354// C callbacks - thin wrappers that cast the context pointer back to &mut Self
355// ---------------------------------------------------------------------------
356
357/// Update interaction regions and live param values.
358///
359/// Takes `&mut BuiltinEditor<P>` so the borrow checker enforces
360/// non-aliasing - the function only touches Rust references and is
361/// fully safe.
362pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
363    match &editor.layout {
364        Layout::Rows(pl) => {
365            editor.interaction.build_regions(pl);
366            let mut flat_idx = 0usize;
367            for row in &pl.rows {
368                for knob_def in &row.knobs {
369                    if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
370                        region.widget_type = resolve_widget_type(
371                            knob_def.widget,
372                            knob_def.param_id,
373                            &*editor.params,
374                        );
375                    }
376                    flat_idx += 1;
377                }
378            }
379        }
380        Layout::Grid(gl) => {
381            editor.interaction.build_regions_grid(gl);
382            for (idx, gw) in gl.widgets.iter().enumerate() {
383                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
384                    region.widget_type =
385                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
386                }
387            }
388        }
389    }
390    for region in &mut editor.interaction.knob_regions {
391        if let Some(ref ctx) = editor.context {
392            // Resolves through `PluginContextReadF32` - bridge's `f64` narrows inside.
393            region.normalized_value = ctx.get_param(region.param_id);
394        } else {
395            region.normalized_value =
396                f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
397        }
398    }
399}
400
401// ---------------------------------------------------------------------------
402// Baseview WindowHandler - drives the CPU render loop
403// ---------------------------------------------------------------------------
404//
405// On macOS + AAX: blits via CoreGraphics (CGImage → CALayer) to avoid Metal
406// autorelease crashes with multiple editor windows.
407// Otherwise: blits via wgpu fullscreen triangle.
408
409fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
410    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
411    desc.backends = wgpu::Backends::PRIMARY;
412    let instance = wgpu::Instance::new(desc);
413
414    let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
415        .expect("failed to create wgpu surface");
416
417    let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
418        power_preference: wgpu::PowerPreference::HighPerformance,
419        compatible_surface: Some(&surface),
420        force_fallback_adapter: false,
421    }))
422    .expect("no suitable GPU adapter");
423
424    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
425        label: Some("truce-gui"),
426        required_features: wgpu::Features::empty(),
427        required_limits: wgpu::Limits::downlevel_defaults(),
428        experimental_features: wgpu::ExperimentalFeatures::default(),
429        memory_hints: wgpu::MemoryHints::Performance,
430        trace: wgpu::Trace::Off,
431    }))
432    .expect("failed to create wgpu device");
433
434    let caps = surface.get_capabilities(&adapter);
435    let format = caps
436        .formats
437        .iter()
438        .find(|f| f.is_srgb())
439        .copied()
440        .unwrap_or(caps.formats[0]);
441
442    let surface_config = wgpu::SurfaceConfiguration {
443        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
444        format,
445        width: phys_w,
446        height: phys_h,
447        present_mode: wgpu::PresentMode::AutoVsync,
448        desired_maximum_frame_latency: 2,
449        alpha_mode: wgpu::CompositeAlphaMode::Auto,
450        view_formats: vec![],
451    };
452    surface.configure(&device, &surface_config);
453
454    // Blit texture matches the CPU pixmap, which is now sized at
455    // physical pixels (see CpuBackend's scale handling). With texture
456    // and surface at the same physical size, the full-screen-triangle
457    // blit samples 1:1 - no stretch, no Retina blur.
458    let blit = crate::blit::BlitPipeline::new(&device, format, phys_w, phys_h);
459
460    BlitBackend {
461        blit,
462        surface_config,
463        surface,
464        queue,
465        device,
466    }
467}
468
469// Field-declaration order doubles as the implicit drop order Rust uses
470// when this struct is dropped through the `Option<BlitBackend>` cell
471// directly (e.g. when the host drops the editor without calling
472// `close`). Children before parent: per-pipeline GPU resources, then
473// the surface (releases swap chain / CAMetalLayer), then queue, then
474// device. `BuiltinEditor::close` does the same thing explicitly via
475// destructure - this declaration order keeps the implicit path safe
476// too.
477struct BlitBackend {
478    blit: crate::blit::BlitPipeline,
479    surface_config: wgpu::SurfaceConfiguration,
480    surface: wgpu::Surface<'static>,
481    queue: wgpu::Queue,
482    device: wgpu::Device,
483}
484
485impl BlitBackend {
486    /// Reconfigure the wgpu surface and blit texture for a new physical
487    /// size. Used when `Editor::set_scale_factor` reports a host-driven
488    /// DPI change - the logical editor size doesn't change, but the
489    /// physical pixmap and surface need to grow / shrink to match.
490    fn resize(&mut self, phys_w: u32, phys_h: u32) {
491        self.surface_config.width = phys_w.max(1);
492        self.surface_config.height = phys_h.max(1);
493        self.surface.configure(&self.device, &self.surface_config);
494        self.blit.resize(&self.device, phys_w, phys_h);
495    }
496}
497
498/// Shared ownership of the blit backend between `BuiltinEditor` and the
499/// `BuiltinWindowHandler` baseview hands us. Sharing lets the editor
500/// drop the wgpu surface *before* it asks baseview to close the
501/// `NSView`. Important on AAX where interleaving Metal teardown with
502/// baseview's close sequence inside Pro Tools' outer autorelease pool
503/// leaves stale refs in DFW container views.
504type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
505
506struct BuiltinWindowHandler<P: Params> {
507    /// Raw pointer to the `BuiltinEditor` owned by the host. Valid only
508    /// while `backend.lock()` returns `Some(_)`. `BuiltinEditor::close`
509    /// takes the inner `Option<BlitBackend>` (atomically through this
510    /// mutex) before returning, and the host can only drop the editor
511    /// after `close()` returns - so any frame that holds the lock and
512    /// finds the inner option `Some` is guaranteed the editor is still
513    /// alive. The lock acquire is the synchronization point that keeps
514    /// an in-flight `on_frame` from dereferencing this pointer after
515    /// the host dropped the editor while baseview's render thread still
516    /// had a callback queued. Only accessed from the GUI thread.
517    editor: *mut BuiltinEditor<P>,
518    backend: SharedBackend,
519    /// Canonical baseview → `InputEvent` translator. Handles cursor
520    /// tracking, double-click synthesis, and line→pixel scroll
521    /// conversion once for everyone.
522    translator: crate::interaction::BaseviewTranslator,
523    /// Last scale we built the CPU pixmap + wgpu surface against.
524    /// `on_frame` reads `editor.scale.get()` (via the raw ptr deref
525    /// it already does) and compares; on divergence it rebuilds the
526    /// pixmap and reconfigures the surface. Unlike egui / iced /
527    /// slint we don't need a separate `EditorScale` clone on the
528    /// handler - the editor is reachable through the same ptr that
529    /// guards the lifecycle, so reading `editor.scale` is the
530    /// canonical access path.
531    last_applied_scale: f32,
532}
533
534// SAFETY: The raw pointer is only accessed from the GUI thread.
535// baseview requires Send for WindowHandler.
536unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
537
538impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
539    fn on_frame(&mut self, _window: &mut baseview::Window) {
540        // Lock the shared backend cell *before* deref'ing `self.editor`.
541        // `BuiltinEditor::close` calls `drop(guard.take())` on the same
542        // mutex before returning; the host then drops the editor. So
543        // either we observe `Some(_)` here (close hasn't taken it yet,
544        // editor still alive) or we observe `None` and return without
545        // touching `self.editor`. Either way the deref below is sound.
546        let Ok(mut guard) = self.backend.lock() else {
547            return;
548        };
549        if guard.is_none() {
550            // Editor already dropped the backend in its close path.
551            // Nothing to do - baseview will tear us down next.
552            return;
553        }
554
555        let editor = unsafe { &mut *self.editor };
556
557        // Pick up host-driven scale changes (CLAP `set_scale`, VST3
558        // `IPlugViewContentScaleSupport`) that landed in the shared
559        // cell since the last frame. The OS-driven `Resized` path
560        // intentionally stays a no-op (resize is disallowed per
561        // `Editor::can_resize`'s `false` default), so this branch is
562        // the only way scale changes propagate.
563        if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
564            let (lw, lh) = editor.size();
565            let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
566            let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
567            editor.backend = CpuBackend::new(lw, lh, cur_scale);
568            if let Some(backend) = guard.as_mut() {
569                backend.resize(phys_w, phys_h);
570            }
571            editor.request_repaint();
572        }
573
574        update_interaction(editor);
575        // Pick up host automation / preset recall that changed params
576        // without going through the UI: flips the dirty bit so the
577        // normal gate below still has the chance to short-circuit when
578        // truly nothing moved.
579        editor.detect_host_param_changes();
580        if !editor.take_needs_repaint() {
581            return;
582        }
583        editor.render();
584        editor.stash_painted_values();
585
586        if let Some(pixels) = editor.pixel_data() {
587            let backend = guard
588                .as_mut()
589                .expect("guard was checked Some above and the lock is still held");
590            let BlitBackend {
591                device,
592                queue,
593                surface,
594                blit,
595                ..
596            } = backend;
597            blit.update(queue, pixels);
598            let (wgpu::CurrentSurfaceTexture::Success(frame)
599            | wgpu::CurrentSurfaceTexture::Suboptimal(frame)) = surface.get_current_texture()
600            else {
601                return;
602            };
603            let view = frame
604                .texture
605                .create_view(&wgpu::TextureViewDescriptor::default());
606            let mut encoder =
607                device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
608            blit.render(&mut encoder, &view);
609            queue.submit(std::iter::once(encoder.finish()));
610            frame.present();
611        }
612    }
613
614    fn on_event(
615        &mut self,
616        window: &mut baseview::Window,
617        event: baseview::Event,
618    ) -> baseview::EventStatus {
619        // `window` is only read on Windows (focus-on-click below);
620        // discard explicitly on other platforms so the lint stays quiet.
621        #[cfg(not(target_os = "windows"))]
622        let _ = &window;
623
624        if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
625            button: baseview::MouseButton::Left,
626            ..
627        }) = &event
628        {
629            // WS_CHILD plugin windows don't receive WM_KEYDOWN
630            // until focused; baseview doesn't SetFocus on click,
631            // so we do it here. Without this, text-edit widgets
632            // never see keystrokes (the DAW keeps eating them for
633            // transport shortcuts).
634            #[cfg(target_os = "windows")]
635            {
636                if !window.has_focus() {
637                    window.focus();
638                }
639            }
640        }
641
642        // Lock-then-check-then-deref pattern, same as `on_frame` -
643        // the backend cell is the synchronization point with
644        // `BuiltinEditor::close`. If the cell is `None`, the editor
645        // pointer is no longer guaranteed valid and we must not deref.
646        let Ok(guard) = self.backend.lock() else {
647            return baseview::EventStatus::Ignored;
648        };
649        if guard.is_none() {
650            return baseview::EventStatus::Ignored;
651        }
652
653        match event {
654            baseview::Event::Mouse(_) => {
655                let Some(input) = self.translator.translate(&event) else {
656                    return baseview::EventStatus::Ignored;
657                };
658                let editor = unsafe { &mut *self.editor };
659                editor.dispatch_events(&[input]);
660                baseview::EventStatus::Captured
661            }
662            baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
663                // Resize is intentionally disallowed: `Editor::can_resize`
664                // and `Editor::set_size` use the trait defaults
665                // (`false` / `false`), so hosts shouldn't drive a resize
666                // through the truce protocol. We still pass the OS-
667                // reported scale through `note_linux_scale_factor` so
668                // newly opened editors on the same process see the
669                // correct DPI from the cache, but we deliberately do
670                // not resize the CPU pixmap or wgpu blit surface - a
671                // user who drags the host window across a DPI boundary
672                // accepts the stretched/cropped output. Matches the
673                // `truce-gpu` `GpuEditor` posture so the two paths
674                // behave identically.
675                crate::platform::note_linux_scale_factor(info.scale());
676                baseview::EventStatus::Ignored
677            }
678            _ => baseview::EventStatus::Ignored,
679        }
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Editor trait implementation
685// ---------------------------------------------------------------------------
686
687/// Resolve widget type: explicit override > auto-detect from param range.
688fn resolve_widget_type<P: Params>(
689    widget: Option<crate::layout::WidgetKind>,
690    param_id: u32,
691    params: &P,
692) -> widgets::WidgetType {
693    match widget {
694        Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
695        Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
696        Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
697        Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
698        Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
699        Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
700        Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
701        None => {
702            let param_info = params
703                .param_infos()
704                .iter()
705                .find(|i| i.id == param_id)
706                .copied();
707            match param_info.as_ref().map(|i| &i.range) {
708                Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
709                    widgets::WidgetType::Toggle
710                }
711                Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
712                _ => widgets::WidgetType::Knob,
713            }
714        }
715    }
716}
717
718impl<P: Params + 'static> Editor for BuiltinEditor<P> {
719    fn size(&self) -> (u32, u32) {
720        (self.layout.width(), self.layout.height())
721    }
722
723    fn state_changed(&mut self) {
724        // Preset recall / undo / session load: params moved without
725        // going through the UI, so force the next idle tick to repaint.
726        self.request_repaint();
727    }
728
729    fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
730        let (w, h) = self.size();
731        // Refresh the shared scale from the parent window - on macOS
732        // this is the live `[NSWindow backingScaleFactor]`, on
733        // Windows the per-monitor DPI from the parent HWND. Any
734        // `set_scale_factor` the host issues after open will overwrite
735        // through the same shared cell.
736        self.scale
737            .set(crate::platform::query_backing_scale(&parent));
738        let scale = self.scale.get();
739        let scale_f32 = self.scale.get_f32();
740        self.backend = CpuBackend::new(w, h, scale_f32);
741        self.context = Some(context);
742
743        // Build interaction regions
744        match &self.layout {
745            Layout::Rows(pl) => self.interaction.build_regions(pl),
746            Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
747        }
748
749        // Render initial frame and flag dirty so the first `on_frame`
750        // blit also runs (the construction default is `false` because a
751        // not-yet-opened editor has nothing to paint to).
752        self.render();
753        self.request_repaint();
754
755        let (lw, lh) = (f64::from(w), f64::from(h));
756        let phys_w = crate::platform::to_physical_px(w, scale);
757        let phys_h = crate::platform::to_physical_px(h, scale);
758
759        let options = baseview::WindowOpenOptions {
760            title: String::from("truce"),
761            size: baseview::Size::new(lw, lh),
762            scale: baseview::WindowScalePolicy::SystemScaleFactor,
763        };
764
765        let parent_wrapper = crate::platform::ParentWindow(parent);
766        let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
767
768        // Shared backend cell: the editor keeps one Arc and baseview's
769        // window handler gets the other. At close time the editor
770        // takes the inner Option and drops it *before* asking baseview
771        // to tear down the NSView.
772        let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
773        self.blit_backend = Some(shared_backend.clone());
774        let shared_for_handler = shared_backend;
775
776        let window = baseview::Window::open_parented(
777            &parent_wrapper,
778            options,
779            move |window: &mut baseview::Window| {
780                let mut backend = create_wgpu_backend(window, phys_w, phys_h);
781
782                // Render + present an initial frame synchronously, before
783                // baseview shows the window. Without this, the window briefly
784                // displays whatever garbage is in the surface buffer until the
785                // first `on_frame` tick - especially noticeable on VST2
786                // (Windows), where `effEditOpen` creates and shows the window
787                // in one call.
788                let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
789                editor.render();
790                if let Some(pixels) = editor.pixel_data() {
791                    let BlitBackend {
792                        device,
793                        queue,
794                        surface,
795                        blit,
796                        ..
797                    } = &mut backend;
798                    blit.update(queue, pixels);
799                    if let wgpu::CurrentSurfaceTexture::Success(frame)
800                    | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
801                        surface.get_current_texture()
802                    {
803                        let view = frame
804                            .texture
805                            .create_view(&wgpu::TextureViewDescriptor::default());
806                        let mut encoder =
807                            device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
808                                label: None,
809                            });
810                        blit.render(&mut encoder, &view);
811                        queue.submit(std::iter::once(encoder.finish()));
812                        frame.present();
813                    }
814                }
815
816                // Publish the backend into the shared cell. If the
817                // editor has already been asked to close (very
818                // unlikely race - only if close fires before baseview
819                // calls our build closure), the None-check on the
820                // mutex side will simply replace Some(None) → Some
821                // and everything drops at the usual time.
822                if let Ok(mut guard) = shared_for_handler.lock() {
823                    *guard = Some(backend);
824                }
825
826                BuiltinWindowHandler {
827                    editor: editor_addr as *mut BuiltinEditor<P>,
828                    backend: shared_for_handler.clone(),
829                    translator: crate::interaction::BaseviewTranslator::default(),
830                    last_applied_scale: scale_f32,
831                }
832            },
833        );
834
835        self.window = Some(window);
836    }
837
838    fn set_scale_factor(&mut self, factor: f64) {
839        // Write to the shared cell; the baseview handler picks up the
840        // change on its next frame and rebuilds the CPU pixmap +
841        // reconfigures the wgpu surface. The trait's default no-op
842        // would silently swallow host scale changes here.
843        self.scale.set(factor);
844    }
845
846    fn close(&mut self) {
847        // On macOS, wrap the teardown in an autoreleasepool so
848        // anything baseview / wgpu / AppKit autoreleases during the
849        // view's cleanup drains here rather than escaping into the
850        // host's outer pool. AAX / Pro Tools is the canonical host
851        // that walks back through residual responders before the
852        // pool drains, surfacing use-after-free crashes.
853        #[cfg(target_os = "macos")]
854        let pool = unsafe {
855            unsafe extern "C" {
856                fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
857            }
858            objc_autoreleasePoolPush()
859        };
860
861        // Drop the wgpu surface (CAMetalLayer, MTLDevice, command
862        // queue, etc.) before asking baseview to release the NSView.
863        // Keeps the Metal teardown order deterministic. The destructure
864        // makes the drop order explicit rather than depending on
865        // `BlitPipeline`'s field-declaration order. Order: per-pipeline
866        // GPU resources first (textures, bind groups, sampler), then
867        // the surface (releases the swap chain / CAMetalLayer), then
868        // queue, then device last - children before parent.
869        if let Some(shared) = self.blit_backend.take()
870            && let Ok(mut guard) = shared.lock()
871            && let Some(backend) = guard.take()
872        {
873            let BlitBackend {
874                blit,
875                surface,
876                surface_config,
877                queue,
878                device,
879            } = backend;
880            drop(surface_config);
881            drop(blit);
882            drop(surface);
883            drop(queue);
884            drop(device);
885        }
886
887        if let Some(mut window) = self.window.take() {
888            window.close();
889        }
890        self.context = None;
891        self.backend = None;
892
893        #[cfg(target_os = "macos")]
894        unsafe {
895            unsafe extern "C" {
896                fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
897            }
898            objc_autoreleasePoolPop(pool);
899        }
900    }
901
902    fn idle(&mut self) {
903        // baseview drives `on_frame` via its internal timer; idle is
904        // only meaningful for the headless/standalone case where the
905        // caller wants a render cycle to pull pixel data out.
906        if self.window.is_none() {
907            self.render();
908        }
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    // Layout-coordinate assertions compare stored anchor values for
915    // bit-exact equality (no arithmetic between them).
916    #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
917
918    use super::*;
919    use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
920    use crate::widgets::WidgetType;
921    use std::sync::Arc;
922    use std::sync::atomic::{AtomicU64, Ordering};
923    use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
924
925    // -- Mock Params with one enum param (4 options) and one float --
926
927    struct TestParams {
928        values: [AtomicU64; 2],
929    }
930
931    impl TestParams {
932        fn new() -> Self {
933            Self {
934                values: [
935                    AtomicU64::new(0.0f64.to_bits()),
936                    AtomicU64::new(0.0f64.to_bits()),
937                ],
938            }
939        }
940    }
941
942    impl truce_params::__private::Sealed for TestParams {}
943    impl Params for TestParams {
944        fn param_infos(&self) -> Vec<ParamInfo> {
945            vec![
946                ParamInfo {
947                    id: 0,
948                    name: "Mode",
949                    short_name: "Mode",
950                    group: "",
951                    range: ParamRange::Enum { count: 4 },
952                    default_plain: 0.0,
953                    flags: ParamFlags::AUTOMATABLE,
954                    unit: ParamUnit::None,
955                    kind: ParamValueKind::Enum,
956                },
957                ParamInfo {
958                    id: 1,
959                    name: "Gain",
960                    short_name: "Gain",
961                    group: "",
962                    range: ParamRange::Linear { min: 0.0, max: 1.0 },
963                    default_plain: 0.5,
964                    flags: ParamFlags::AUTOMATABLE,
965                    unit: ParamUnit::None,
966                    kind: ParamValueKind::Float,
967                },
968            ]
969        }
970
971        fn count(&self) -> usize {
972            2
973        }
974
975        fn get_normalized(&self, id: u32) -> Option<f64> {
976            self.values
977                .get(id as usize)
978                .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
979        }
980
981        fn set_normalized(&self, id: u32, value: f64) {
982            if let Some(v) = self.values.get(id as usize) {
983                v.store(value.to_bits(), Ordering::Relaxed);
984            }
985        }
986
987        fn get_plain(&self, id: u32) -> Option<f64> {
988            let norm = self.get_normalized(id)?;
989            let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
990            Some(info.range.denormalize(norm))
991        }
992
993        fn set_plain(&self, id: u32, value: f64) {
994            if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
995                self.set_normalized(id, info.range.normalize(value));
996            }
997        }
998
999        fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1000            Some(format!("{value:.0}"))
1001        }
1002
1003        fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1004            None
1005        }
1006        fn snap_smoothers(&self) {}
1007        fn set_sample_rate(&self, _: f64) {}
1008
1009        fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1010            let ids = vec![0, 1];
1011            let vals: Vec<f64> = ids
1012                .iter()
1013                .map(|&id| self.get_plain(id).unwrap_or(0.0))
1014                .collect();
1015            (ids, vals)
1016        }
1017
1018        fn restore_values(&self, values: &[(u32, f64)]) {
1019            for &(id, val) in values {
1020                self.set_plain(id, val);
1021            }
1022        }
1023    }
1024
1025    impl Default for TestParams {
1026        fn default() -> Self {
1027            Self::new()
1028        }
1029    }
1030
1031    // -- Helpers --
1032
1033    /// Build a `BuiltinEditor` with a dropdown at position 0 and a knob at position 1.
1034    fn make_editor() -> BuiltinEditor<TestParams> {
1035        let params = Arc::new(TestParams::new());
1036        let layout = GridLayout::build(vec![widgets(vec![
1037            GridWidget::dropdown(0u32, "Mode"),
1038            GridWidget::knob(1u32, "Gain"),
1039        ])]);
1040        let mut editor = BuiltinEditor::new_grid(params, layout);
1041        // Build interaction regions (normally done in open/render)
1042        if let Layout::Grid(ref gl) = editor.layout {
1043            editor.interaction.build_regions_grid(gl);
1044            for (idx, gw) in gl.widgets.iter().enumerate() {
1045                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1046                    region.widget_type =
1047                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1048                }
1049            }
1050        }
1051        // Render once to populate dropdown_anchor_y
1052        editor.render();
1053        editor
1054    }
1055
1056    /// Build an editor with section breaks to test anchor stability.
1057    fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1058        let params = Arc::new(TestParams::new());
1059        let layout = GridLayout::build(vec![
1060            section(
1061                "SECTION A",
1062                vec![
1063                    GridWidget::knob(1u32, "Gain"),
1064                    GridWidget::knob(1u32, "Gain 2"),
1065                ],
1066            ),
1067            section(
1068                "SECTION B",
1069                vec![
1070                    GridWidget::dropdown(0u32, "Mode"),
1071                    GridWidget::knob(1u32, "Gain 3"),
1072                ],
1073            ),
1074        ]);
1075        let mut editor = BuiltinEditor::new_grid(params, layout);
1076        if let Layout::Grid(ref gl) = editor.layout {
1077            editor.interaction.build_regions_grid(gl);
1078            for (idx, gw) in gl.widgets.iter().enumerate() {
1079                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1080                    region.widget_type =
1081                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1082                }
1083            }
1084        }
1085        editor.render();
1086        editor
1087    }
1088
1089    /// Find the center of the first dropdown widget's region.
1090    fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1091        let region = editor
1092            .interaction
1093            .knob_regions
1094            .iter()
1095            .find(|r| r.widget_type == WidgetType::Dropdown)
1096            .expect("no dropdown in layout");
1097        (region.x + region.w / 2.0, region.y + region.h / 2.0)
1098    }
1099
1100    // -- Tests: dropdown close-on-reclick --
1101
1102    #[test]
1103    fn dropdown_click_opens() {
1104        let mut editor = make_editor();
1105        let (dx, dy) = dropdown_center(&editor);
1106
1107        editor.on_mouse_down(dx, dy);
1108        assert!(editor.interaction.dropdown_is_open());
1109    }
1110
1111    #[test]
1112    fn dropdown_click_toggles_closed() {
1113        let mut editor = make_editor();
1114        let (dx, dy) = dropdown_center(&editor);
1115
1116        // Open
1117        editor.on_mouse_down(dx, dy);
1118        editor.on_mouse_up(dx, dy);
1119        assert!(editor.interaction.dropdown_is_open());
1120
1121        // Click same button again - should close, not reopen
1122        editor.on_mouse_down(dx, dy);
1123        assert!(!editor.interaction.dropdown_is_open());
1124    }
1125
1126    #[test]
1127    fn dropdown_click_outside_closes() {
1128        let mut editor = make_editor();
1129        let (dx, dy) = dropdown_center(&editor);
1130
1131        editor.on_mouse_down(dx, dy);
1132        editor.on_mouse_up(dx, dy);
1133        assert!(editor.interaction.dropdown_is_open());
1134
1135        // Click far away
1136        editor.on_mouse_down(0.0, 0.0);
1137        assert!(!editor.interaction.dropdown_is_open());
1138    }
1139
1140    #[test]
1141    fn dropdown_click_option_selects_and_closes() {
1142        let mut editor = make_editor();
1143        let (dx, dy) = dropdown_center(&editor);
1144
1145        editor.on_mouse_down(dx, dy);
1146        editor.on_mouse_up(dx, dy);
1147        assert!(editor.interaction.dropdown_is_open());
1148
1149        // Click the second option (index 1) inside the popup
1150        let dd = editor.interaction.dropdown.as_ref().unwrap();
1151        let (px, py, _, _) = dd.popup_rect;
1152        let item_h = 18.0f32;
1153        let padding = 4.0f32;
1154        let option_y = py + padding + item_h + item_h / 2.0; // middle of second item
1155
1156        // Touch model: down then up at the same point commits the
1157        // option under the release point. (Down alone starts a
1158        // popup-drag - the up handler decides commit-vs-scroll.)
1159        editor.on_mouse_down(px + 10.0, option_y);
1160        editor.on_mouse_up(px + 10.0, option_y);
1161
1162        assert!(!editor.interaction.dropdown_is_open());
1163        // Enum{count:4} → step_count=3 → 4 options. Index 1 → norm = 1/3
1164        let norm = editor.params.get_normalized(0).unwrap();
1165        let expected = 1.0 / 3.0;
1166        assert!(
1167            (norm - expected).abs() < 0.01,
1168            "expected {expected:.4}, got {norm}"
1169        );
1170    }
1171
1172    // -- Tests: dropdown anchor positioning --
1173
1174    #[test]
1175    fn dropdown_anchor_set_after_render() {
1176        let editor = make_editor();
1177        let region = editor
1178            .interaction
1179            .knob_regions
1180            .iter()
1181            .find(|r| r.widget_type == WidgetType::Dropdown)
1182            .unwrap();
1183
1184        // Anchor should be within the widget region (below y, above y+h)
1185        assert!(
1186            region.dropdown_anchor_y > region.y,
1187            "anchor {} should be below region.y {}",
1188            region.dropdown_anchor_y,
1189            region.y
1190        );
1191        assert!(
1192            region.dropdown_anchor_y < region.y + region.h,
1193            "anchor {} should be above region bottom {}",
1194            region.dropdown_anchor_y,
1195            region.y + region.h
1196        );
1197    }
1198
1199    #[test]
1200    fn dropdown_popup_uses_anchor() {
1201        let mut editor = make_editor();
1202        let (dx, dy) = dropdown_center(&editor);
1203
1204        editor.on_mouse_down(dx, dy);
1205        editor.on_mouse_up(dx, dy);
1206
1207        let dd = editor.interaction.dropdown.as_ref().unwrap();
1208        let region = &editor.interaction.knob_regions[dd.region_idx];
1209
1210        // popup_y must equal the stored anchor - popup always
1211        // anchors directly below the button (scrolls on tight
1212        // editors rather than relocating).
1213        assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1214    }
1215
1216    #[test]
1217    fn dropdown_anchor_gap_stable_with_sections() {
1218        let editor_plain = make_editor();
1219        let editor_sections = make_editor_with_sections();
1220
1221        let r_plain = editor_plain
1222            .interaction
1223            .knob_regions
1224            .iter()
1225            .find(|r| r.widget_type == WidgetType::Dropdown)
1226            .unwrap();
1227        let r_sections = editor_sections
1228            .interaction
1229            .knob_regions
1230            .iter()
1231            .find(|r| r.widget_type == WidgetType::Dropdown)
1232            .unwrap();
1233
1234        // The gap from widget vertical center to anchor should be identical
1235        // regardless of section offsets shifting the absolute Y position.
1236        let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1237        let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1238        assert!(
1239            (gap_plain - gap_sections).abs() < 0.1,
1240            "gap_plain={gap_plain}, gap_sections={gap_sections}"
1241        );
1242    }
1243
1244    // -- Mock Params with a large enum (20 options) for overflow/scroll tests --
1245
1246    struct ManyOptionParams {
1247        values: [AtomicU64; 2],
1248    }
1249
1250    impl ManyOptionParams {
1251        fn new() -> Self {
1252            Self {
1253                values: [
1254                    AtomicU64::new(0.0f64.to_bits()),
1255                    AtomicU64::new(0.0f64.to_bits()),
1256                ],
1257            }
1258        }
1259    }
1260
1261    impl truce_params::__private::Sealed for ManyOptionParams {}
1262    impl Params for ManyOptionParams {
1263        fn param_infos(&self) -> Vec<ParamInfo> {
1264            vec![
1265                ParamInfo {
1266                    id: 0,
1267                    name: "Note",
1268                    short_name: "Note",
1269                    group: "",
1270                    range: ParamRange::Enum { count: 20 },
1271                    default_plain: 0.0,
1272                    flags: ParamFlags::AUTOMATABLE,
1273                    unit: ParamUnit::None,
1274                    kind: ParamValueKind::Enum,
1275                },
1276                ParamInfo {
1277                    id: 1,
1278                    name: "Gain",
1279                    short_name: "Gain",
1280                    group: "",
1281                    range: ParamRange::Linear { min: 0.0, max: 1.0 },
1282                    default_plain: 0.5,
1283                    flags: ParamFlags::AUTOMATABLE,
1284                    unit: ParamUnit::None,
1285                    kind: ParamValueKind::Float,
1286                },
1287            ]
1288        }
1289
1290        fn count(&self) -> usize {
1291            2
1292        }
1293
1294        fn get_normalized(&self, id: u32) -> Option<f64> {
1295            self.values
1296                .get(id as usize)
1297                .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1298        }
1299
1300        fn set_normalized(&self, id: u32, value: f64) {
1301            if let Some(v) = self.values.get(id as usize) {
1302                v.store(value.to_bits(), Ordering::Relaxed);
1303            }
1304        }
1305
1306        fn get_plain(&self, id: u32) -> Option<f64> {
1307            let norm = self.get_normalized(id)?;
1308            let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1309            Some(info.range.denormalize(norm))
1310        }
1311
1312        fn set_plain(&self, id: u32, value: f64) {
1313            if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1314                self.set_normalized(id, info.range.normalize(value));
1315            }
1316        }
1317
1318        fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1319            Some(format!("{value:.0}"))
1320        }
1321
1322        fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1323            None
1324        }
1325        fn snap_smoothers(&self) {}
1326        fn set_sample_rate(&self, _: f64) {}
1327
1328        fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1329            let ids = vec![0, 1];
1330            let vals: Vec<f64> = ids
1331                .iter()
1332                .map(|&id| self.get_plain(id).unwrap_or(0.0))
1333                .collect();
1334            (ids, vals)
1335        }
1336
1337        fn restore_values(&self, values: &[(u32, f64)]) {
1338            for &(id, val) in values {
1339                self.set_plain(id, val);
1340            }
1341        }
1342    }
1343
1344    impl Default for ManyOptionParams {
1345        fn default() -> Self {
1346            Self::new()
1347        }
1348    }
1349
1350    // -- Additional helpers --
1351
1352    /// Build an editor with a dropdown in the last row (near the window bottom).
1353    fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1354        let params = Arc::new(TestParams::new());
1355        // 3 rows of 2, dropdown in the last row (row 2)
1356        let layout = GridLayout::build(vec![widgets(vec![
1357            GridWidget::knob(1u32, "K1"),
1358            GridWidget::knob(1u32, "K2"),
1359            GridWidget::knob(1u32, "K3"),
1360            GridWidget::knob(1u32, "K4"),
1361            GridWidget::dropdown(0u32, "Mode"),
1362            GridWidget::knob(1u32, "K5"),
1363        ])])
1364        .with_cols(2);
1365        let mut editor = BuiltinEditor::new_grid(params, layout);
1366        if let Layout::Grid(ref gl) = editor.layout {
1367            editor.interaction.build_regions_grid(gl);
1368            for (idx, gw) in gl.widgets.iter().enumerate() {
1369                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1370                    region.widget_type =
1371                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1372                }
1373            }
1374        }
1375        editor.render();
1376        editor
1377    }
1378
1379    /// Build an editor with two dropdowns side by side.
1380    fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1381        let params = Arc::new(TestParams::new());
1382        let layout = GridLayout::build(vec![widgets(vec![
1383            GridWidget::dropdown(0u32, "Mode A"),
1384            GridWidget::dropdown(0u32, "Mode B"),
1385        ])]);
1386        let mut editor = BuiltinEditor::new_grid(params, layout);
1387        if let Layout::Grid(ref gl) = editor.layout {
1388            editor.interaction.build_regions_grid(gl);
1389            for (idx, gw) in gl.widgets.iter().enumerate() {
1390                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1391                    region.widget_type =
1392                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1393                }
1394            }
1395        }
1396        editor.render();
1397        editor
1398    }
1399
1400    /// Build an editor with a 20-option dropdown for scroll testing.
1401    fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1402        let params = Arc::new(ManyOptionParams::new());
1403        let layout = GridLayout::build(vec![widgets(vec![
1404            GridWidget::dropdown(0u32, "Note"),
1405            GridWidget::knob(1u32, "Gain"),
1406        ])]);
1407        let mut editor = BuiltinEditor::new_grid(params, layout);
1408        if let Layout::Grid(ref gl) = editor.layout {
1409            editor.interaction.build_regions_grid(gl);
1410            for (idx, gw) in gl.widgets.iter().enumerate() {
1411                if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1412                    region.widget_type =
1413                        resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1414                }
1415            }
1416        }
1417        editor.render();
1418        editor
1419    }
1420
1421    fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
1422        let region = editor
1423            .interaction
1424            .knob_regions
1425            .iter()
1426            .find(|r| r.widget_type == WidgetType::Dropdown)
1427            .expect("no dropdown in layout");
1428        (region.x + region.w / 2.0, region.y + region.h / 2.0)
1429    }
1430
1431    // -- Tests: dropdown overflow/clipping --
1432
1433    #[test]
1434    fn dropdown_anchors_below_button_scrolls_when_tight() {
1435        let mut editor = make_editor_bottom_dropdown();
1436        let (dx, dy) = {
1437            let region = editor
1438                .interaction
1439                .knob_regions
1440                .iter()
1441                .find(|r| r.widget_type == WidgetType::Dropdown)
1442                .unwrap();
1443            (region.x + region.w / 2.0, region.y + region.h / 2.0)
1444        };
1445
1446        editor.on_mouse_down(dx, dy);
1447        editor.on_mouse_up(dx, dy);
1448        assert!(editor.interaction.dropdown_is_open());
1449
1450        let dd = editor.interaction.dropdown.as_ref().unwrap();
1451        let region = &editor.interaction.knob_regions[dd.region_idx];
1452        let (_, popup_y, _, popup_h) = dd.popup_rect;
1453        let window_h = editor.layout.height() as f32;
1454
1455        // Popup anchors at the button's bottom - never shifts up
1456        // and never flips above. If the full option list doesn't
1457        // fit between the anchor and the window bottom, the popup
1458        // scrolls instead of relocating away from the tap target.
1459        assert_eq!(
1460            popup_y, region.dropdown_anchor_y,
1461            "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
1462        );
1463        // Popup never extends past the window bottom.
1464        assert!(
1465            popup_y + popup_h <= window_h + 1.0,
1466            "popup bottom {} exceeds window height {window_h}",
1467            popup_y + popup_h
1468        );
1469    }
1470
1471    #[test]
1472    fn dropdown_clamps_horizontal_near_right_edge() {
1473        let mut editor = make_editor_two_dropdowns();
1474        // The second dropdown is in column 1 (right side)
1475        let region = &editor.interaction.knob_regions[1];
1476        assert_eq!(region.widget_type, WidgetType::Dropdown);
1477        let dx = region.x + region.w / 2.0;
1478        let dy = region.y + region.h / 2.0;
1479
1480        editor.on_mouse_down(dx, dy);
1481        editor.on_mouse_up(dx, dy);
1482        assert!(editor.interaction.dropdown_is_open());
1483
1484        let dd = editor.interaction.dropdown.as_ref().unwrap();
1485        let (popup_x, _, popup_w, _) = dd.popup_rect;
1486        let window_w = editor.layout.width() as f32;
1487
1488        assert!(
1489            popup_x + popup_w <= window_w + 1.0,
1490            "popup right edge {} exceeds window width {window_w}",
1491            popup_x + popup_w
1492        );
1493        assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
1494    }
1495
1496    #[test]
1497    fn dropdown_scroll_long_list() {
1498        let mut editor = make_editor_many_options();
1499        let (dx, dy) = dropdown_center_many(&editor);
1500
1501        editor.on_mouse_down(dx, dy);
1502        editor.on_mouse_up(dx, dy);
1503        assert!(editor.interaction.dropdown_is_open());
1504
1505        let dd = editor.interaction.dropdown.as_ref().unwrap();
1506        // 20-option enum → step_count = 19 → 19 options
1507        assert!(
1508            dd.options.len() > dd.visible_count,
1509            "expected scroll: {} options, {} visible",
1510            dd.options.len(),
1511            dd.visible_count
1512        );
1513        assert_eq!(dd.scroll_offset, 0);
1514    }
1515
1516    #[test]
1517    fn dropdown_scroll_clamps_to_bounds() {
1518        let mut editor = make_editor_many_options();
1519        let (dx, dy) = dropdown_center_many(&editor);
1520
1521        editor.on_mouse_down(dx, dy);
1522        editor.on_mouse_up(dx, dy);
1523
1524        // Scroll up past the top - should stay at 0
1525        editor.interaction.dropdown_scroll(-10);
1526        assert_eq!(
1527            editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1528            0
1529        );
1530
1531        // Scroll down past the bottom - should clamp
1532        editor.interaction.dropdown_scroll(1000);
1533        let dd = editor.interaction.dropdown.as_ref().unwrap();
1534        let max_offset = dd.options.len().saturating_sub(dd.visible_count);
1535        assert_eq!(dd.scroll_offset, max_offset);
1536    }
1537
1538    #[test]
1539    fn dropdown_selected_item_visible_on_open() {
1540        let mut editor = make_editor_many_options();
1541        // Set the value to option 15 out of 19 (normalized = 15/18)
1542        editor.params.set_normalized(0, 15.0 / 18.0);
1543
1544        let (dx, dy) = dropdown_center_many(&editor);
1545        editor.on_mouse_down(dx, dy);
1546        editor.on_mouse_up(dx, dy);
1547
1548        let dd = editor.interaction.dropdown.as_ref().unwrap();
1549        let selected = dd.selected;
1550        // The selected item should be within the visible window
1551        assert!(
1552            selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
1553            "selected={selected} not in visible range {}..{}",
1554            dd.scroll_offset,
1555            dd.scroll_offset + dd.visible_count
1556        );
1557    }
1558
1559    #[test]
1560    fn dropdown_scroll_then_select_correct_index() {
1561        let mut editor = make_editor_many_options();
1562        let (dx, dy) = dropdown_center_many(&editor);
1563
1564        editor.on_mouse_down(dx, dy);
1565        editor.on_mouse_up(dx, dy);
1566
1567        // Scroll down by 3
1568        editor.interaction.dropdown_scroll(3);
1569        assert_eq!(
1570            editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1571            3
1572        );
1573
1574        // Click the second visible item (local index 1 → absolute index 4)
1575        let dd = editor.interaction.dropdown.as_ref().unwrap();
1576        let (px, py, _, _) = dd.popup_rect;
1577        let item_h = 18.0f32;
1578        let padding = 4.0f32;
1579        let click_y = py + padding + item_h + item_h / 2.0; // middle of second visible item
1580
1581        editor.on_mouse_down(px + 10.0, click_y);
1582        editor.on_mouse_up(px + 10.0, click_y);
1583
1584        assert!(!editor.interaction.dropdown_is_open());
1585        // Absolute index = scroll_offset(3) + local(1) = 4
1586        // 20 options → norm = 4/19
1587        let norm = editor.params.get_normalized(0).unwrap();
1588        let expected = 4.0 / 19.0;
1589        assert!(
1590            (norm - expected).abs() < 0.01,
1591            "expected {expected:.4}, got {norm:.4}"
1592        );
1593    }
1594
1595    #[test]
1596    fn dropdown_click_different_dropdown_closes_first() {
1597        let mut editor = make_editor_two_dropdowns();
1598        let r0 = &editor.interaction.knob_regions[0];
1599        let r1 = &editor.interaction.knob_regions[1];
1600        let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
1601        let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
1602
1603        // Open dropdown A
1604        editor.on_mouse_down(ax, ay);
1605        editor.on_mouse_up(ax, ay);
1606        assert!(editor.interaction.dropdown_is_open());
1607        assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
1608
1609        // Click dropdown B - should close A and open B
1610        editor.on_mouse_down(bx, by);
1611        editor.on_mouse_up(bx, by);
1612        assert!(editor.interaction.dropdown_is_open());
1613        assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
1614    }
1615
1616    #[test]
1617    fn dropdown_hover_tracks_correct_option() {
1618        let mut editor = make_editor();
1619        let (dx, dy) = dropdown_center(&editor);
1620
1621        editor.on_mouse_down(dx, dy);
1622        editor.on_mouse_up(dx, dy);
1623
1624        let dd = editor.interaction.dropdown.as_ref().unwrap();
1625        let (px, py, pw, _) = dd.popup_rect;
1626        let item_h = 18.0f32;
1627        let padding = 4.0f32;
1628        let last_visible = dd.visible_count - 1;
1629
1630        // Hover over the last visible item
1631        let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
1632        editor.on_mouse_moved(px + pw / 2.0, hover_y);
1633
1634        let dd = editor.interaction.dropdown.as_ref().unwrap();
1635        assert_eq!(
1636            dd.hover_option,
1637            Some(last_visible),
1638            "expected hover on last visible option"
1639        );
1640
1641        // Move outside the popup
1642        editor.on_mouse_moved(0.0, 0.0);
1643        let dd = editor.interaction.dropdown.as_ref().unwrap();
1644        assert_eq!(dd.hover_option, None, "hover should clear outside popup");
1645    }
1646
1647    #[test]
1648    fn dropdown_popup_within_window_bounds() {
1649        // Verify popup never exceeds window in any direction
1650        let mut editor = make_editor();
1651        let (dx, dy) = dropdown_center(&editor);
1652
1653        editor.on_mouse_down(dx, dy);
1654        editor.on_mouse_up(dx, dy);
1655
1656        let dd = editor.interaction.dropdown.as_ref().unwrap();
1657        let (px, py, pw, ph) = dd.popup_rect;
1658        let window_w = editor.layout.width() as f32;
1659        let window_h = editor.layout.height() as f32;
1660
1661        assert!(px >= 0.0, "popup left edge {px} < 0");
1662        assert!(py >= 0.0, "popup top edge {py} < 0");
1663        assert!(
1664            px + pw <= window_w + 1.0,
1665            "popup right {} > window {window_w}",
1666            px + pw
1667        );
1668        assert!(
1669            py + ph <= window_h + 1.0,
1670            "popup bottom {} > window {window_h}",
1671            py + ph
1672        );
1673    }
1674}