Skip to main content

truce_gui/
editor.rs

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