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