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}