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