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