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 })
771}
772
773/// The pump's init product: everything the GUI thread needs to encode
774/// the blit. Field-declaration order doubles as the implicit drop
775/// order - children before parent: per-pipeline GPU resources, then
776/// queue, then device. (The surface itself lives with the pump and
777/// drops with it.)
778#[cfg(feature = "cpu")]
779struct BlitParts {
780 blit: crate::blit::BlitPipeline,
781 /// Local bookkeeping copy; the authoritative configure happens on
782 /// the pump.
783 surface_config: wgpu::SurfaceConfiguration,
784 queue: wgpu::Queue,
785 device: wgpu::Device,
786 /// Adapter-reported `max_texture_dimension_2d`. `resize` clamps
787 /// each axis against this before reconfiguring so a host- or
788 /// DPI-driven resize past the device's texture cap can't trip a
789 /// wgpu validation panic (which unwinds out of the editor on the
790 /// host's UI thread and aborts the standalone / the DAW).
791 max_texture_dim: u32,
792}
793
794/// The blit pipeline plus the surface pump that owns its swapchain.
795/// `parts` stays `None` until the pump finishes GPU init (immediately
796/// on macOS / Linux, where init runs inline).
797#[cfg(feature = "cpu")]
798struct BlitBackend {
799 client: truce_gpu::pump::PumpClient,
800 parts: Option<BlitParts>,
801 pump: truce_gpu::pump::SurfacePump<BlitParts>,
802}
803
804#[cfg(feature = "cpu")]
805impl BlitBackend {
806 /// The pump's init product, adopting it if it just landed. `None`
807 /// while GPU init is still running (or after it failed).
808 fn parts_mut(&mut self) -> Option<&mut BlitParts> {
809 if self.parts.is_none()
810 && let Some(parts) = self.pump.take_init()
811 {
812 self.parts = Some(parts);
813 }
814 self.parts.as_mut()
815 }
816
817 /// Reconfigure the wgpu surface and blit texture for a new physical
818 /// size. Used when `Editor::set_scale_factor` reports a host-driven
819 /// DPI change - the logical editor size doesn't change, but the
820 /// physical pixmap and surface need to grow / shrink to match.
821 fn resize(&mut self, phys_w: u32, phys_h: u32) {
822 let client = self.client.clone();
823 let Some(parts) = self.parts_mut() else {
824 return;
825 };
826 let phys_w = phys_w.clamp(1, parts.max_texture_dim);
827 let phys_h = phys_h.clamp(1, parts.max_texture_dim);
828 parts.surface_config.width = phys_w;
829 parts.surface_config.height = phys_h;
830 client.resize(phys_w, phys_h);
831 parts.blit.resize(&parts.device, phys_w, phys_h);
832 }
833
834 /// Reconfigure only the swapchain surface to a new physical size,
835 /// leaving the blit texture (the CPU pixmap source) untouched.
836 ///
837 /// The surface must track the window's *real* physical extent so it
838 /// always covers it. That extent is set by the WM (X11, now
839 /// cell-snapped via resize-increment hints) or the host, and is not
840 /// bit-identical to `to_physical_px(logical, scale)` - sizing the
841 /// surface from the logical value instead leaves the window's
842 /// trailing edge showing whatever is behind it. The blit draws the
843 /// texture at native size on a pixel-snapped centre, so a surface a
844 /// few px larger than the texture letterboxes that difference to
845 /// black (no stretch, no garbage) rather than rescaling. Called from
846 /// the `Resized` handler, where the window's actual physical size is
847 /// authoritative.
848 fn configure_surface(&mut self, phys_w: u32, phys_h: u32) {
849 let client = self.client.clone();
850 let Some(parts) = self.parts_mut() else {
851 return;
852 };
853 let phys_w = phys_w.clamp(1, parts.max_texture_dim);
854 let phys_h = phys_h.clamp(1, parts.max_texture_dim);
855 if parts.surface_config.width == phys_w && parts.surface_config.height == phys_h {
856 return;
857 }
858 parts.surface_config.width = phys_w;
859 parts.surface_config.height = phys_h;
860 client.resize(phys_w, phys_h);
861 }
862}
863
864/// Shared ownership of the blit backend between `BuiltinEditor` and the
865/// `BuiltinWindowHandler` baseview hands us. Sharing lets the editor
866/// drop the wgpu surface *before* it asks baseview to close the
867/// `NSView`. Important on AAX where interleaving Metal teardown with
868/// baseview's close sequence inside Pro Tools' outer autorelease pool
869/// leaves stale refs in DFW container views.
870#[cfg(feature = "cpu")]
871type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
872
873#[cfg(feature = "cpu")]
874struct BuiltinWindowHandler<P: Params> {
875 /// Raw pointer to the `BuiltinEditor` owned by the host. Valid only
876 /// while `backend.lock()` returns `Some(_)`. `BuiltinEditor::close`
877 /// takes the inner `Option<BlitBackend>` (atomically through this
878 /// mutex) before returning, and the host can only drop the editor
879 /// after `close()` returns - so any frame that holds the lock and
880 /// finds the inner option `Some` is guaranteed the editor is still
881 /// alive. The lock acquire is the synchronization point that keeps
882 /// an in-flight `on_frame` from dereferencing this pointer after
883 /// the host dropped the editor while baseview's render thread still
884 /// had a callback queued. Only accessed from the GUI thread.
885 editor: *mut BuiltinEditor<P>,
886 backend: SharedBackend,
887 /// Canonical baseview → `InputEvent` translator. Handles cursor
888 /// tracking, double-click synthesis, and line→pixel scroll
889 /// conversion once for everyone.
890 translator: crate::interaction::BaseviewTranslator,
891 /// Paces paints to the compositor's measured consumption rate so
892 /// per-tick repaints (meters) can't park the host's GUI thread in
893 /// the swapchain acquire - see [`crate::PaintPacer`].
894 pacer: crate::platform::PaintPacer,
895 /// Last scale we built the CPU pixmap + wgpu surface against.
896 /// `on_frame` reads `editor.scale.get()` (via the raw ptr deref
897 /// it already does) and compares; on divergence it rebuilds the
898 /// pixmap and reconfigures the surface. Unlike egui / iced /
899 /// slint we don't need a separate `EditorScale` clone on the
900 /// handler - the editor is reachable through the same ptr that
901 /// guards the lifecycle, so reading `editor.scale` is the
902 /// canonical access path.
903 last_applied_scale: f32,
904 /// Enforces min/max/aspect on host resizes that bypassed the
905 /// format's negotiation hooks (Linux hosts resizing the embed
906 /// window directly).
907 resize_corrector: ResizeCorrector,
908}
909
910// SAFETY: The raw pointer is only accessed from the GUI thread.
911// baseview requires Send for WindowHandler.
912#[cfg(feature = "cpu")]
913unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
914
915#[cfg(feature = "cpu")]
916impl<P: Params + 'static> BuiltinWindowHandler<P> {
917 fn on_frame_inner(&mut self, window: &mut baseview::Window) {
918 // Lock the shared backend cell *before* deref'ing `self.editor`.
919 // `BuiltinEditor::close` calls `drop(guard.take())` on the same
920 // mutex before returning; the host then drops the editor. So
921 // either we observe `Some(_)` here (close hasn't taken it yet,
922 // editor still alive) or we observe `None` and return without
923 // touching `self.editor`. Either way the deref below is sound.
924 let Ok(mut guard) = self.backend.lock() else {
925 return;
926 };
927 if guard.is_none() {
928 // Editor already dropped the backend in its close path.
929 // Nothing to do - baseview will tear us down next.
930 return;
931 }
932
933 let editor = unsafe { &mut *self.editor };
934
935 // Pick up host-driven `set_size` requests posted to the
936 // shared `pending_size` cell since the last frame. The
937 // editor's `set_size` has already snapped to a whole
938 // column count and reflowed the grid via
939 // `GridLayout::refit_cols`; here we rebuild the CPU pixmap
940 // at the new logical size, reconfigure the wgpu blit
941 // surface to the new physical extent, refresh the
942 // interaction-region cache against the post-reflow widget
943 // layout, and resize the baseview window itself so the
944 // host's outer container follows. Same handoff pattern the
945 // egui / iced / slint editors use.
946 let pending = editor.pending_size.swap(0, Ordering::Acquire);
947 if pending != 0 {
948 #[allow(clippy::cast_possible_truncation)]
949 let new_w = (pending >> 32) as u32;
950 #[allow(clippy::cast_possible_truncation)]
951 let new_h = (pending & 0xFFFF_FFFF) as u32;
952 if new_w > 0 && new_h > 0 {
953 let scale = editor.scale.get();
954 let scale_f32 = editor.scale.get_f32();
955 let phys_w = crate::platform::to_physical_px(new_w, scale);
956 let phys_h = crate::platform::to_physical_px(new_h, scale);
957 editor.backend = CpuBackend::new(new_w, new_h, scale_f32);
958 if let Some(backend) = guard.as_mut() {
959 backend.resize(phys_w, phys_h);
960 }
961 match &editor.layout {
962 Layout::Rows(pl) => editor.interaction.build_regions(pl),
963 Layout::Grid(gl) => editor.interaction.build_regions_grid(gl),
964 }
965 window.resize(baseview::Size::new(f64::from(new_w), f64::from(new_h)));
966 editor.request_repaint();
967 }
968 }
969
970 // Re-anchor on every frame so any host-driven drift of the
971 // child `NSView`'s origin gets corrected before the next
972 // paint. The wrapper installs `MinYMargin | MaxXMargin`
973 // (via `anchor_child_to_top`) on the child, which keeps the
974 // child top-anchored across *parent-driven* resizes - but
975 // both the editor resizing itself (via `window.resize`
976 // above) and the host reseating the child via its own
977 // `setFrameOrigin:` call (REAPER's plug-in framework does
978 // this) bypass AppKit's autoresize math. The result is a
979 // child whose top edge drifts off the host pane and the
980 // editor's GAIN header / knob row clip above the visible
981 // area while the canvas's empty trailing space + bottom
982 // labels show inside. Running every frame is cheap - it's
983 // one Cocoa frame query and a no-op short-circuit when
984 // already anchored - and is the cleanest place to assert
985 // the invariant the wrapper expects.
986 // Skip the whole frame while the editor isn't presentable:
987 // detached / occluded on macOS, host child window hidden /
988 // minimized on Windows (no-op on Linux).
989 {
990 use raw_window_handle::HasRawWindowHandle;
991 if crate::platform::should_skip_frame(window.raw_window_handle()) {
992 return;
993 }
994 }
995 #[cfg(target_os = "macos")]
996 {
997 use raw_window_handle::HasRawWindowHandle;
998 crate::platform::reanchor_to_superview_top(window.raw_window_handle());
999 }
1000
1001 // Pick up scale changes that landed in the shared cell since
1002 // the last frame - either from a host callback (CLAP
1003 // `set_scale`, VST3 `IPlugViewContentScaleSupport`) or from
1004 // the OS-driven `Resized` path writing through `info.scale()`.
1005 // Logical w×h is fixed when resize is disallowed; only the
1006 // logical→physical ratio moves through here.
1007 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
1008 let (lw, lh) = editor.size();
1009 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
1010 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
1011 editor.backend = CpuBackend::new(lw, lh, cur_scale);
1012 if let Some(backend) = guard.as_mut() {
1013 backend.resize(phys_w, phys_h);
1014 }
1015 editor.request_repaint();
1016 }
1017
1018 update_interaction(editor);
1019 // Pick up host automation / preset recall that changed params
1020 // without going through the UI: flips the dirty bit so the
1021 // normal gate below still has the chance to short-circuit when
1022 // truly nothing moved.
1023 editor.detect_host_param_changes();
1024 editor.detect_meter_changes();
1025 // Compositor pacing veto - before `take_needs_repaint` so the
1026 // dirty bit survives the held ticks and the deferred paint
1027 // still happens. Windows skips the veto: the pump pre-acquires
1028 // frames off-thread and `try_take_frame` returning `None`
1029 // already paces paints to the compositor, so holding here only
1030 // adds latency.
1031 if cfg!(not(target_os = "windows")) && self.pacer.should_hold() {
1032 return;
1033 }
1034 if !editor.take_needs_repaint() {
1035 return;
1036 }
1037 // Get the pump's frame BEFORE rasterizing or uploading. During
1038 // resize churn no frame is available (the pump is busy
1039 // reconfiguring); skipping everything here saves the wasted
1040 // CPU raster and keeps queue work (texture upload, submit) off
1041 // the GUI thread while the pump's configure is in flight -
1042 // those contend on wgpu's internal locks. On Windows the take
1043 // never blocks (pump pre-acquires); elsewhere it acquires
1044 // inline with the usual stale-surface recovery.
1045 let client = {
1046 let backend = guard
1047 .as_mut()
1048 .expect("guard was checked Some above and the lock is still held");
1049 if backend.parts_mut().is_none() {
1050 // GPU init still pending on the pump (Windows) or
1051 // failed; re-arm the dirty bit so the first ready
1052 // frame paints instead of waiting for the next edit.
1053 editor.request_repaint();
1054 return;
1055 }
1056 backend.client.clone()
1057 };
1058 let frame = client.try_take_frame();
1059 self.pacer.record_acquire(client.last_acquire_wait());
1060 let Some(frame) = frame else {
1061 // Windows: the pump is still acquiring - re-arm the
1062 // dirty bit so the paint lands when the frame is
1063 // ready. Elsewhere `None` is a transient Timeout /
1064 // Occluded; skip and let the next edit repaint.
1065 #[cfg(target_os = "windows")]
1066 editor.request_repaint();
1067 return;
1068 };
1069 editor.render();
1070 editor.stash_painted_values();
1071
1072 if let Some(pixels) = editor.pixel_data() {
1073 let backend = guard
1074 .as_mut()
1075 .expect("guard was checked Some above and the lock is still held");
1076 let Some(parts) = backend.parts_mut() else {
1077 client.discard(frame);
1078 editor.request_repaint();
1079 return;
1080 };
1081 let BlitParts {
1082 device,
1083 queue,
1084 surface_config,
1085 blit,
1086 ..
1087 } = parts;
1088 // A resize raced the acquire: the frame is at the old
1089 // extent; discard it (the pump reconfigures + reacquires).
1090 if (frame.texture.width(), frame.texture.height())
1091 != (surface_config.width, surface_config.height)
1092 {
1093 client.discard(frame);
1094 editor.request_repaint();
1095 return;
1096 }
1097 blit.update(queue, pixels);
1098 let view = frame
1099 .texture
1100 .create_view(&wgpu::TextureViewDescriptor::default());
1101 let mut encoder =
1102 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1103 blit.render(
1104 queue,
1105 &mut encoder,
1106 &view,
1107 surface_config.width,
1108 surface_config.height,
1109 );
1110 queue.submit(std::iter::once(encoder.finish()));
1111 client.present(frame);
1112 } else {
1113 client.discard(frame);
1114 }
1115 }
1116
1117 // Mirrors the by-value `WindowHandler::on_event` signature it's
1118 // called from; pedantic clippy can't tell that the `match event`
1119 // arms only bind `Copy` fields.
1120 #[allow(clippy::needless_pass_by_value)]
1121 fn on_event_inner(
1122 &mut self,
1123 window: &mut baseview::Window,
1124 event: baseview::Event,
1125 ) -> baseview::EventStatus {
1126 // `window` is only read on Windows (focus-on-click below);
1127 // discard explicitly on other platforms so the lint stays quiet.
1128 #[cfg(not(target_os = "windows"))]
1129 let _ = &window;
1130
1131 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
1132 button: baseview::MouseButton::Left,
1133 ..
1134 }) = &event
1135 {
1136 // WS_CHILD plugin windows don't receive WM_KEYDOWN
1137 // until focused; baseview doesn't SetFocus on click,
1138 // so we do it here. Without this, text-edit widgets
1139 // never see keystrokes (the DAW keeps eating them for
1140 // transport shortcuts).
1141 #[cfg(target_os = "windows")]
1142 {
1143 if !window.has_focus() {
1144 window.focus();
1145 }
1146 }
1147 }
1148
1149 // Lock-then-check-then-deref pattern, same as `on_frame` -
1150 // the backend cell is the synchronization point with
1151 // `BuiltinEditor::close`. If the cell is `None`, the editor
1152 // pointer is no longer guaranteed valid and we must not deref.
1153 let Ok(mut guard) = self.backend.lock() else {
1154 return baseview::EventStatus::Ignored;
1155 };
1156 if guard.is_none() {
1157 return baseview::EventStatus::Ignored;
1158 }
1159
1160 match event {
1161 baseview::Event::Mouse(_) => {
1162 let Some(input) = self.translator.translate(&event) else {
1163 return baseview::EventStatus::Ignored;
1164 };
1165 let editor = unsafe { &mut *self.editor };
1166 editor.dispatch_events(&[input]);
1167 baseview::EventStatus::Captured
1168 }
1169 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1170 // Two things can flow through `Resized`:
1171 // - A backing-scale change (monitor-boundary drag,
1172 // host calling `set_scale_factor`): logical w×h is
1173 // invariant, only `info.scale()` matters.
1174 // - A logical resize via the autoresize cascade
1175 // (host grows the parent NSView with our child
1176 // tagged `WidthSizable | HeightSizable`, or the
1177 // standalone window grows around us). For
1178 // resizable editors we route the new bounds into
1179 // `set_size` so the grid reflows; fixed-size
1180 // editors stay pinned.
1181 let editor = unsafe { &mut *self.editor };
1182 editor.scale.set(info.scale());
1183 crate::platform::note_linux_scale_factor(info.scale());
1184 let phys = info.physical_size();
1185 if editor.can_resize() {
1186 let scale = info.scale();
1187 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1188 let (lw, lh) = if scale > 0.0 {
1189 (
1190 (f64::from(phys.width) / scale).round() as u32,
1191 (f64::from(phys.height) / scale).round() as u32,
1192 )
1193 } else {
1194 (phys.width, phys.height)
1195 };
1196 if lw > 0 && lh > 0 {
1197 // A host that resized the embed window directly
1198 // never ran the format's constraint preflight -
1199 // fit here and push the corrected size back.
1200 let ((fw, fh), correct) = self.resize_corrector.fit(
1201 lw,
1202 lh,
1203 editor.min_size(),
1204 editor.max_size(),
1205 editor.aspect_ratio(),
1206 );
1207 if (fw, fh) != editor.size() {
1208 editor.set_size(fw, fh);
1209 }
1210 if let Some((rw, rh)) = correct {
1211 // On Linux, hosts that bypass size negotiation
1212 // (Bitwig) ignore this request and react by
1213 // *growing* the embed window - a resize loop.
1214 // Clamp the content (and counter-resize our child)
1215 // but never ask the host to resize its frame.
1216 // mac/windows honor it (and negotiate via
1217 // `checkSizeConstraint`) anyway.
1218 #[cfg(not(target_os = "linux"))]
1219 if let Some(ctx) = editor.context.as_ref() {
1220 let _ = ctx.request_resize(rw, rh);
1221 }
1222 #[cfg(target_os = "linux")]
1223 let _ = (rw, rh);
1224 }
1225 }
1226 }
1227 // Keep the swapchain covering the window's *actual*
1228 // physical size. The WM (X11 resize-increment snap) or
1229 // host sets that size, and it isn't bit-identical to the
1230 // `to_physical_px(logical)` the `on_frame` resize paths
1231 // configure the surface to - so without this the trailing
1232 // edge of the window shows whatever is behind it. Driving
1233 // the surface from the authoritative `info.physical_size()`
1234 // here closes that gap; the blit letterboxes any ≤few-px
1235 // difference to black on a pixel-snapped centre (no
1236 // stretch), so a fixed editor under a host that wobbles
1237 // the embed size ±1px stays crisp instead of shimmering.
1238 if phys.width > 0
1239 && phys.height > 0
1240 && let Some(backend) = guard.as_mut()
1241 {
1242 backend.configure_surface(phys.width, phys.height);
1243 }
1244 // Always repaint on a `Resized`, even when the logical
1245 // size is unchanged. Our own `set_size` -> `on_frame`
1246 // resize is asynchronous on X11: `on_frame` reconfigures
1247 // the surface and presents one frame *before* the
1248 // `ConfigureNotify` actually grows the child window, then
1249 // clears the dirty bit. The trailing `Resized` that
1250 // reports the now-grown window carries a logical size
1251 // that already matches `editor.size()`, so without this
1252 // the gate short-circuits and the freshly exposed region
1253 // is never painted - it shows whatever was behind the
1254 // window until the next unrelated repaint.
1255 editor.request_repaint();
1256 baseview::EventStatus::Ignored
1257 }
1258 _ => baseview::EventStatus::Ignored,
1259 }
1260 }
1261}
1262
1263#[cfg(feature = "cpu")]
1264impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
1265 fn on_frame(&mut self, window: &mut baseview::Window) {
1266 // Catch panics at the FFI boundary. baseview calls us through
1267 // an `extern "C-unwind"` AppKit override; an unwinding Rust
1268 // panic becomes an ObjC exception and `NSApplication run`
1269 // rethrows it, terminating the host. Swallow the panic and
1270 // log it so the host stays alive.
1271 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1272 self.on_frame_inner(window);
1273 }));
1274 if let Err(e) = result {
1275 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1276 s.to_string()
1277 } else if let Some(s) = e.downcast_ref::<String>() {
1278 s.clone()
1279 } else {
1280 "unknown panic".to_string()
1281 };
1282 log::error!("BuiltinWindowHandler::on_frame panic swallowed: {msg}");
1283 }
1284 }
1285
1286 fn on_event(
1287 &mut self,
1288 window: &mut baseview::Window,
1289 event: baseview::Event,
1290 ) -> baseview::EventStatus {
1291 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1292 self.on_event_inner(window, event)
1293 }));
1294 result.unwrap_or_else(|e| {
1295 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1296 s.to_string()
1297 } else if let Some(s) = e.downcast_ref::<String>() {
1298 s.clone()
1299 } else {
1300 "unknown panic".to_string()
1301 };
1302 log::error!("BuiltinWindowHandler::on_event panic swallowed: {msg}");
1303 baseview::EventStatus::Ignored
1304 })
1305 }
1306}
1307
1308// ---------------------------------------------------------------------------
1309// Editor trait implementation
1310// ---------------------------------------------------------------------------
1311
1312/// Resolve widget type: explicit override > auto-detect from param range.
1313fn resolve_widget_type<P: Params>(
1314 widget: Option<crate::layout::WidgetKind>,
1315 param_id: u32,
1316 params: &P,
1317) -> widgets::WidgetType {
1318 match widget {
1319 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1320 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1321 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1322 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1323 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1324 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1325 None => {
1326 let param_info = params
1327 .param_infos()
1328 .iter()
1329 .find(|i| i.id == param_id)
1330 .copied();
1331 match param_info.as_ref().map(|i| &i.range) {
1332 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1333 widgets::WidgetType::Toggle
1334 }
1335 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1336 _ => widgets::WidgetType::Knob,
1337 }
1338 }
1339 }
1340}
1341
1342#[cfg(feature = "cpu")]
1343impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1344 fn size(&self) -> (u32, u32) {
1345 (self.layout.width(), self.layout.height())
1346 }
1347
1348 fn state_changed(&mut self) {
1349 // Preset recall / undo / session load: params moved without
1350 // going through the UI, so force the next idle tick to repaint.
1351 self.request_repaint();
1352 }
1353
1354 // These forward to the inherent methods of the same name (inherent
1355 // methods win method resolution, so `self.foo()` is not recursive).
1356 // The logic lives inherently so the gpu-only `GpuEditor` wrapper can
1357 // reach it when this `Editor` impl is cfg'd out.
1358 fn can_resize(&self) -> bool {
1359 self.can_resize()
1360 }
1361
1362 fn can_maximize(&self) -> bool {
1363 self.can_maximize()
1364 }
1365
1366 fn min_size(&self) -> (u32, u32) {
1367 self.min_size()
1368 }
1369
1370 fn max_size(&self) -> (u32, u32) {
1371 self.max_size()
1372 }
1373
1374 fn size_increment(&self) -> Option<(u32, u32)> {
1375 self.size_increment()
1376 }
1377
1378 fn set_size(&mut self, width: u32, height: u32) -> bool {
1379 self.set_size(width, height)
1380 }
1381
1382 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1383 let (w, h) = self.size();
1384 // Drop any stale `set_size` that fired before this `open()`
1385 // so the next frame doesn't immediately re-resize the
1386 // freshly-built window to a previous request.
1387 self.pending_size.store(0, Ordering::Relaxed);
1388 // Refresh the shared scale from the parent window - on macOS
1389 // this is the live `[NSWindow backingScaleFactor]`, on
1390 // Windows the per-monitor DPI from the parent HWND. Any
1391 // `set_scale_factor` the host issues after open will overwrite
1392 // through the same shared cell.
1393 // Pick the baseview scale policy. On Linux an embedded plugin
1394 // follows the host's scale (default 1.0) rather than the desktop
1395 // Xft.dpi, which a non-DPI-aware host (Bitwig) doesn't share; the
1396 // standalone and every macOS/Windows path keep SystemScaleFactor.
1397 let scale_policy = if let Some(s) = crate::platform::editor_window_scale(
1398 self.use_system_scale,
1399 self.host_scale_set,
1400 self.scale.get(),
1401 ) {
1402 self.scale.set(s);
1403 baseview::WindowScalePolicy::ScaleFactor(s)
1404 } else {
1405 self.scale
1406 .set(crate::platform::query_backing_scale(&parent));
1407 baseview::WindowScalePolicy::SystemScaleFactor
1408 };
1409 let scale = self.scale.get();
1410 let scale_f32 = self.scale.get_f32();
1411 self.backend = CpuBackend::new(w, h, scale_f32);
1412 self.context = Some(context);
1413
1414 // Build interaction regions
1415 match &self.layout {
1416 Layout::Rows(pl) => self.interaction.build_regions(pl),
1417 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1418 }
1419
1420 // Render initial frame and flag dirty so the first `on_frame`
1421 // blit also runs (the construction default is `false` because a
1422 // not-yet-opened editor has nothing to paint to).
1423 self.render();
1424 self.request_repaint();
1425
1426 let (lw, lh) = (f64::from(w), f64::from(h));
1427 let phys_w = crate::platform::to_physical_px(w, scale);
1428 let phys_h = crate::platform::to_physical_px(h, scale);
1429
1430 let options = baseview::WindowOpenOptions {
1431 title: String::from("truce"),
1432 size: baseview::Size::new(lw, lh),
1433 scale: scale_policy,
1434 };
1435
1436 let parent_wrapper = crate::platform::ParentWindow(parent);
1437 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1438
1439 // Shared backend cell: the editor keeps one Arc and baseview's
1440 // window handler gets the other. At close time the editor
1441 // takes the inner Option and drops it *before* asking baseview
1442 // to tear down the NSView.
1443 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1444 self.blit_backend = Some(shared_backend.clone());
1445 let shared_for_handler = shared_backend;
1446
1447 let window = baseview::Window::open_parented(
1448 &parent_wrapper,
1449 options,
1450 move |window: &mut baseview::Window| {
1451 let backend = create_wgpu_backend(window, phys_w, phys_h);
1452
1453 // Render + present an initial frame synchronously, before
1454 // baseview shows the window. Without this, the window briefly
1455 // displays whatever garbage is in the surface buffer until the
1456 // first `on_frame` tick - especially noticeable on VST2
1457 // (Windows), where `effEditOpen` creates and shows the window
1458 // in one call. On Windows the pump is still initializing here
1459 // (`parts_mut` is `None`), so this paint is skipped and the
1460 // dirty bit set at `open()` covers the first ready frame.
1461 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1462 editor.render();
1463 let mut backend = backend;
1464 if let Some(pixels) = editor.pixel_data()
1465 && let Some(backend) = backend.as_mut()
1466 {
1467 let client = backend.client.clone();
1468 if let Some(parts) = backend.parts_mut() {
1469 let BlitParts {
1470 device,
1471 queue,
1472 surface_config,
1473 blit,
1474 ..
1475 } = parts;
1476 blit.update(queue, pixels);
1477 if let Some(frame) = client.try_take_frame() {
1478 let view = frame
1479 .texture
1480 .create_view(&wgpu::TextureViewDescriptor::default());
1481 let mut encoder =
1482 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1483 label: None,
1484 });
1485 blit.render(
1486 queue,
1487 &mut encoder,
1488 &view,
1489 surface_config.width,
1490 surface_config.height,
1491 );
1492 queue.submit(std::iter::once(encoder.finish()));
1493 client.present(frame);
1494 }
1495 }
1496 }
1497
1498 // Publish the backend into the shared cell. If the
1499 // editor has already been asked to close (very
1500 // unlikely race - only if close fires before baseview
1501 // calls our build closure), the None-check on the
1502 // mutex side will simply replace Some(None) → Some
1503 // and everything drops at the usual time.
1504 if let Ok(mut guard) = shared_for_handler.lock() {
1505 *guard = backend;
1506 }
1507
1508 BuiltinWindowHandler {
1509 editor: editor_addr as *mut BuiltinEditor<P>,
1510 backend: shared_for_handler.clone(),
1511 translator: crate::interaction::BaseviewTranslator::default(),
1512 last_applied_scale: scale_f32,
1513 pacer: crate::platform::PaintPacer::default(),
1514 resize_corrector: ResizeCorrector::default(),
1515 }
1516 },
1517 );
1518
1519 self.window = Some(window);
1520 }
1521
1522 fn set_scale_factor(&mut self, factor: f64) {
1523 // Write to the shared cell; the baseview handler picks up the
1524 // change on its next frame and rebuilds the CPU pixmap +
1525 // reconfigures the wgpu surface. The trait's default no-op
1526 // would silently swallow host scale changes here.
1527 self.host_scale_set = true;
1528 self.scale.set(factor);
1529 }
1530
1531 fn set_uses_system_scale(&mut self, yes: bool) {
1532 self.use_system_scale = yes;
1533 }
1534
1535 fn close(&mut self) {
1536 // On macOS, wrap the teardown in an autoreleasepool so
1537 // anything baseview / wgpu / AppKit autoreleases during the
1538 // view's cleanup drains here rather than escaping into the
1539 // host's outer pool. AAX / Pro Tools is the canonical host
1540 // that walks back through residual responders before the
1541 // pool drains, surfacing use-after-free crashes.
1542 #[cfg(target_os = "macos")]
1543 let pool = unsafe {
1544 unsafe extern "C" {
1545 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1546 }
1547 objc_autoreleasePoolPush()
1548 };
1549
1550 // Drop the wgpu surface (CAMetalLayer, MTLDevice, command
1551 // queue, etc.) before asking baseview to release the NSView.
1552 // Keeps the Metal teardown order deterministic. The destructure
1553 // makes the drop order explicit rather than depending on
1554 // `BlitPipeline`'s field-declaration order. Order: per-pipeline
1555 // GPU resources first (textures, bind groups, sampler), then
1556 // the pump (which owns and releases the surface / swap chain /
1557 // CAMetalLayer), then queue, then device last - children
1558 // before parent.
1559 if let Some(shared) = self.blit_backend.take()
1560 && let Ok(mut guard) = shared.lock()
1561 && let Some(backend) = guard.take()
1562 {
1563 let BlitBackend {
1564 client,
1565 parts,
1566 pump,
1567 } = backend;
1568 if let Some(BlitParts {
1569 blit,
1570 surface_config,
1571 queue,
1572 device,
1573 max_texture_dim: _,
1574 }) = parts
1575 {
1576 drop(surface_config);
1577 drop(blit);
1578 drop(client);
1579 drop(pump);
1580 drop(queue);
1581 drop(device);
1582 } else {
1583 drop(client);
1584 drop(pump);
1585 }
1586 }
1587
1588 if let Some(mut window) = self.window.take() {
1589 window.close();
1590 }
1591 self.context = None;
1592 self.backend = None;
1593
1594 #[cfg(target_os = "macos")]
1595 unsafe {
1596 unsafe extern "C" {
1597 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1598 }
1599 objc_autoreleasePoolPop(pool);
1600 }
1601 }
1602
1603 fn idle(&mut self) {
1604 // baseview drives `on_frame` via its internal timer; idle is
1605 // only meaningful for the headless/standalone case where the
1606 // caller wants a render cycle to pull pixel data out.
1607 if self.window.is_none() {
1608 self.render();
1609 }
1610 }
1611
1612 fn screenshot(
1613 &mut self,
1614 _params: Arc<dyn truce_params::Params>,
1615 ) -> Option<(Vec<u8>, u32, u32)> {
1616 // Headless render of the widget tree into a fresh
1617 // `CpuBackend` at the live content scale. Mirrors
1618 // `GpuEditor::screenshot`'s shape: same `render_to` call
1619 // path, same physical-size rounding so reference PNGs baked
1620 // on either backend match dimensions exactly. Used by
1621 // `truce_test::assert_screenshot::<P>()`.
1622 let (lw, lh) = self.size();
1623 let scale = self.scale.get_f32();
1624 let mut backend = CpuBackend::new(lw, lh, scale)?;
1625 self.render_to(&mut backend);
1626 let pixels = backend.data().to_vec();
1627 let (phys_w, phys_h) = (backend.width(), backend.height());
1628 Some((pixels, phys_w, phys_h))
1629 }
1630}
1631
1632#[cfg(feature = "cpu")]
1633impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1634 fn drop(&mut self) {
1635 // The baseview `WindowHandle` does not cancel the macOS frame
1636 // timer when it drops, and the NSView keeps its own strong
1637 // `Rc<WindowState>`, so the timer keeps firing `on_frame`
1638 // against the handler's raw `*mut BuiltinEditor`. If the host
1639 // drops us without calling `Editor::close` first, that pointer
1640 // dangles the moment our fields (`scale`, the shared backend)
1641 // are freed - the next tick deref'd freed memory and crashes in
1642 // `EditorScale::take_change`. Run the same teardown here so the
1643 // timer is always cancelled before our fields go away; it is
1644 // idempotent via the `Option::take`s, so a prior `close` makes
1645 // this a no-op.
1646 Editor::close(self);
1647 }
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652 // Layout-coordinate assertions compare stored anchor values for
1653 // bit-exact equality (no arithmetic between them).
1654 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1655
1656 use super::*;
1657 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1658 use crate::widgets::WidgetType;
1659 use std::sync::Arc;
1660 use std::sync::atomic::{AtomicU64, Ordering};
1661 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1662
1663 // -- Mock Params with one enum param (4 options) and one float --
1664
1665 struct TestParams {
1666 values: [AtomicU64; 2],
1667 }
1668
1669 impl TestParams {
1670 fn new() -> Self {
1671 Self {
1672 values: [
1673 AtomicU64::new(0.0f64.to_bits()),
1674 AtomicU64::new(0.0f64.to_bits()),
1675 ],
1676 }
1677 }
1678 }
1679
1680 impl truce_params::__private::Sealed for TestParams {}
1681 impl Params for TestParams {
1682 fn param_infos(&self) -> Vec<ParamInfo> {
1683 vec![
1684 ParamInfo {
1685 id: 0,
1686 name: "Mode",
1687 short_name: "Mode",
1688 group: "",
1689 range: ParamRange::Enum { count: 4 },
1690 default_plain: 0.0,
1691 flags: ParamFlags::AUTOMATABLE,
1692 unit: ParamUnit::None,
1693 kind: ParamValueKind::Enum,
1694 midi_map: None,
1695 midi_channel: None,
1696 },
1697 ParamInfo {
1698 id: 1,
1699 name: "Gain",
1700 short_name: "Gain",
1701 group: "",
1702 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1703 default_plain: 0.5,
1704 flags: ParamFlags::AUTOMATABLE,
1705 unit: ParamUnit::None,
1706 kind: ParamValueKind::Float,
1707 midi_map: None,
1708 midi_channel: None,
1709 },
1710 ]
1711 }
1712
1713 fn count(&self) -> usize {
1714 2
1715 }
1716
1717 fn get_normalized(&self, id: u32) -> Option<f64> {
1718 self.values
1719 .get(id as usize)
1720 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1721 }
1722
1723 fn set_normalized(&self, id: u32, value: f64) {
1724 if let Some(v) = self.values.get(id as usize) {
1725 v.store(value.to_bits(), Ordering::Relaxed);
1726 }
1727 }
1728
1729 fn get_plain(&self, id: u32) -> Option<f64> {
1730 let norm = self.get_normalized(id)?;
1731 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1732 Some(info.range.denormalize(norm))
1733 }
1734
1735 fn set_plain(&self, id: u32, value: f64) {
1736 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1737 self.set_normalized(id, info.range.normalize(value));
1738 }
1739 }
1740
1741 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1742 Some(format!("{value:.0}"))
1743 }
1744
1745 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1746 None
1747 }
1748 fn snap_smoothers(&self) {}
1749 fn set_sample_rate(&self, _: f64) {}
1750
1751 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1752 let ids = vec![0, 1];
1753 let vals: Vec<f64> = ids
1754 .iter()
1755 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1756 .collect();
1757 (ids, vals)
1758 }
1759
1760 fn restore_values(&self, values: &[(u32, f64)]) {
1761 for &(id, val) in values {
1762 self.set_plain(id, val);
1763 }
1764 }
1765 }
1766
1767 impl Default for TestParams {
1768 fn default() -> Self {
1769 Self::new()
1770 }
1771 }
1772
1773 // -- Helpers --
1774
1775 /// Build a `BuiltinEditor` with a dropdown at position 0 and a knob at position 1.
1776 fn make_editor() -> BuiltinEditor<TestParams> {
1777 let params = Arc::new(TestParams::new());
1778 let layout = GridLayout::build(vec![widgets(vec![
1779 GridWidget::dropdown(0u32, "Mode"),
1780 GridWidget::knob(1u32, "Gain"),
1781 ])]);
1782 let mut editor = BuiltinEditor::new_grid(params, layout);
1783 // Build interaction regions (normally done in open/render)
1784 if let Layout::Grid(ref gl) = editor.layout {
1785 editor.interaction.build_regions_grid(gl);
1786 for (idx, gw) in gl.widgets.iter().enumerate() {
1787 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1788 region.widget_type =
1789 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1790 }
1791 }
1792 }
1793 // Render once to populate dropdown_anchor_y
1794 editor.render();
1795 editor
1796 }
1797
1798 /// Build an editor with section breaks to test anchor stability.
1799 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1800 let params = Arc::new(TestParams::new());
1801 let layout = GridLayout::build(vec![
1802 section(
1803 "SECTION A",
1804 vec![
1805 GridWidget::knob(1u32, "Gain"),
1806 GridWidget::knob(1u32, "Gain 2"),
1807 ],
1808 ),
1809 section(
1810 "SECTION B",
1811 vec![
1812 GridWidget::dropdown(0u32, "Mode"),
1813 GridWidget::knob(1u32, "Gain 3"),
1814 ],
1815 ),
1816 ]);
1817 let mut editor = BuiltinEditor::new_grid(params, layout);
1818 if let Layout::Grid(ref gl) = editor.layout {
1819 editor.interaction.build_regions_grid(gl);
1820 for (idx, gw) in gl.widgets.iter().enumerate() {
1821 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1822 region.widget_type =
1823 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1824 }
1825 }
1826 }
1827 editor.render();
1828 editor
1829 }
1830
1831 /// Find the center of the first dropdown widget's region.
1832 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1833 let region = editor
1834 .interaction
1835 .knob_regions
1836 .iter()
1837 .find(|r| r.widget_type == WidgetType::Dropdown)
1838 .expect("no dropdown in layout");
1839 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1840 }
1841
1842 // -- Tests: dropdown close-on-reclick --
1843
1844 #[test]
1845 fn dropdown_click_opens() {
1846 let mut editor = make_editor();
1847 let (dx, dy) = dropdown_center(&editor);
1848
1849 editor.on_mouse_down(dx, dy);
1850 assert!(editor.interaction.dropdown_is_open());
1851 }
1852
1853 #[test]
1854 fn dropdown_click_toggles_closed() {
1855 let mut editor = make_editor();
1856 let (dx, dy) = dropdown_center(&editor);
1857
1858 // Open
1859 editor.on_mouse_down(dx, dy);
1860 editor.on_mouse_up(dx, dy);
1861 assert!(editor.interaction.dropdown_is_open());
1862
1863 // Click same button again - should close, not reopen
1864 editor.on_mouse_down(dx, dy);
1865 assert!(!editor.interaction.dropdown_is_open());
1866 }
1867
1868 #[test]
1869 fn dropdown_click_outside_closes() {
1870 let mut editor = make_editor();
1871 let (dx, dy) = dropdown_center(&editor);
1872
1873 editor.on_mouse_down(dx, dy);
1874 editor.on_mouse_up(dx, dy);
1875 assert!(editor.interaction.dropdown_is_open());
1876
1877 // Click far away
1878 editor.on_mouse_down(0.0, 0.0);
1879 assert!(!editor.interaction.dropdown_is_open());
1880 }
1881
1882 #[test]
1883 fn dropdown_click_option_selects_and_closes() {
1884 let mut editor = make_editor();
1885 let (dx, dy) = dropdown_center(&editor);
1886
1887 editor.on_mouse_down(dx, dy);
1888 editor.on_mouse_up(dx, dy);
1889 assert!(editor.interaction.dropdown_is_open());
1890
1891 // Click the second option (index 1) inside the popup
1892 let dd = editor.interaction.dropdown.as_ref().unwrap();
1893 let (px, py, _, _) = dd.popup_rect;
1894 let item_h = 18.0f32;
1895 let padding = 4.0f32;
1896 let option_y = py + padding + item_h + item_h / 2.0; // middle of second item
1897
1898 // Touch model: down then up at the same point commits the
1899 // option under the release point. (Down alone starts a
1900 // popup-drag - the up handler decides commit-vs-scroll.)
1901 editor.on_mouse_down(px + 10.0, option_y);
1902 editor.on_mouse_up(px + 10.0, option_y);
1903
1904 assert!(!editor.interaction.dropdown_is_open());
1905 // Enum{count:4} → step_count=3 → 4 options. Index 1 → norm = 1/3
1906 let norm = editor.params.get_normalized(0).unwrap();
1907 let expected = 1.0 / 3.0;
1908 assert!(
1909 (norm - expected).abs() < 0.01,
1910 "expected {expected:.4}, got {norm}"
1911 );
1912 }
1913
1914 // -- Tests: dropdown anchor positioning --
1915
1916 #[test]
1917 fn dropdown_anchor_set_after_render() {
1918 let editor = make_editor();
1919 let region = editor
1920 .interaction
1921 .knob_regions
1922 .iter()
1923 .find(|r| r.widget_type == WidgetType::Dropdown)
1924 .unwrap();
1925
1926 // Anchor should be within the widget region (below y, above y+h)
1927 assert!(
1928 region.dropdown_anchor_y > region.y,
1929 "anchor {} should be below region.y {}",
1930 region.dropdown_anchor_y,
1931 region.y
1932 );
1933 assert!(
1934 region.dropdown_anchor_y < region.y + region.h,
1935 "anchor {} should be above region bottom {}",
1936 region.dropdown_anchor_y,
1937 region.y + region.h
1938 );
1939 }
1940
1941 #[test]
1942 fn dropdown_popup_uses_anchor() {
1943 let mut editor = make_editor();
1944 let (dx, dy) = dropdown_center(&editor);
1945
1946 editor.on_mouse_down(dx, dy);
1947 editor.on_mouse_up(dx, dy);
1948
1949 let dd = editor.interaction.dropdown.as_ref().unwrap();
1950 let region = &editor.interaction.knob_regions[dd.region_idx];
1951
1952 // popup_y must equal the stored anchor - popup always
1953 // anchors directly below the button (scrolls on tight
1954 // editors rather than relocating).
1955 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1956 }
1957
1958 #[test]
1959 fn dropdown_anchor_survives_idle_rebuild() {
1960 // Regression: the CPU `on_frame` runs `update_interaction`
1961 // (which rebuilds regions) every frame, but gates `render`
1962 // behind a repaint check. On an idle frame the rebuild ran
1963 // without a following render, resetting `dropdown_anchor_y`
1964 // to 0 and stranding the next dropdown popup at the top of
1965 // the window. The rebuild must preserve the anchor.
1966 let mut editor = make_editor();
1967
1968 // Simulate an idle frame: regions rebuilt, no render after.
1969 update_interaction(&mut editor);
1970
1971 let (dx, dy) = dropdown_center(&editor);
1972 editor.on_mouse_down(dx, dy);
1973 editor.on_mouse_up(dx, dy);
1974
1975 let dd = editor.interaction.dropdown.as_ref().unwrap();
1976 let region = &editor.interaction.knob_regions[dd.region_idx];
1977 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1978 assert!(
1979 dd.popup_rect.1 > region.y,
1980 "popup_y {} fell back to the window top instead of anchoring below the button",
1981 dd.popup_rect.1
1982 );
1983 }
1984
1985 #[test]
1986 fn dropdown_anchor_gap_stable_with_sections() {
1987 let editor_plain = make_editor();
1988 let editor_sections = make_editor_with_sections();
1989
1990 let r_plain = editor_plain
1991 .interaction
1992 .knob_regions
1993 .iter()
1994 .find(|r| r.widget_type == WidgetType::Dropdown)
1995 .unwrap();
1996 let r_sections = editor_sections
1997 .interaction
1998 .knob_regions
1999 .iter()
2000 .find(|r| r.widget_type == WidgetType::Dropdown)
2001 .unwrap();
2002
2003 // The gap from widget vertical center to anchor should be identical
2004 // regardless of section offsets shifting the absolute Y position.
2005 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
2006 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
2007 assert!(
2008 (gap_plain - gap_sections).abs() < 0.1,
2009 "gap_plain={gap_plain}, gap_sections={gap_sections}"
2010 );
2011 }
2012
2013 // -- Mock Params with a large enum (20 options) for overflow/scroll tests --
2014
2015 struct ManyOptionParams {
2016 values: [AtomicU64; 2],
2017 }
2018
2019 impl ManyOptionParams {
2020 fn new() -> Self {
2021 Self {
2022 values: [
2023 AtomicU64::new(0.0f64.to_bits()),
2024 AtomicU64::new(0.0f64.to_bits()),
2025 ],
2026 }
2027 }
2028 }
2029
2030 impl truce_params::__private::Sealed for ManyOptionParams {}
2031 impl Params for ManyOptionParams {
2032 fn param_infos(&self) -> Vec<ParamInfo> {
2033 vec![
2034 ParamInfo {
2035 id: 0,
2036 name: "Note",
2037 short_name: "Note",
2038 group: "",
2039 range: ParamRange::Enum { count: 20 },
2040 default_plain: 0.0,
2041 flags: ParamFlags::AUTOMATABLE,
2042 unit: ParamUnit::None,
2043 kind: ParamValueKind::Enum,
2044 midi_map: None,
2045 midi_channel: None,
2046 },
2047 ParamInfo {
2048 id: 1,
2049 name: "Gain",
2050 short_name: "Gain",
2051 group: "",
2052 range: ParamRange::Linear { min: 0.0, max: 1.0 },
2053 default_plain: 0.5,
2054 flags: ParamFlags::AUTOMATABLE,
2055 unit: ParamUnit::None,
2056 kind: ParamValueKind::Float,
2057 midi_map: None,
2058 midi_channel: None,
2059 },
2060 ]
2061 }
2062
2063 fn count(&self) -> usize {
2064 2
2065 }
2066
2067 fn get_normalized(&self, id: u32) -> Option<f64> {
2068 self.values
2069 .get(id as usize)
2070 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
2071 }
2072
2073 fn set_normalized(&self, id: u32, value: f64) {
2074 if let Some(v) = self.values.get(id as usize) {
2075 v.store(value.to_bits(), Ordering::Relaxed);
2076 }
2077 }
2078
2079 fn get_plain(&self, id: u32) -> Option<f64> {
2080 let norm = self.get_normalized(id)?;
2081 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
2082 Some(info.range.denormalize(norm))
2083 }
2084
2085 fn set_plain(&self, id: u32, value: f64) {
2086 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
2087 self.set_normalized(id, info.range.normalize(value));
2088 }
2089 }
2090
2091 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
2092 Some(format!("{value:.0}"))
2093 }
2094
2095 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
2096 None
2097 }
2098 fn snap_smoothers(&self) {}
2099 fn set_sample_rate(&self, _: f64) {}
2100
2101 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
2102 let ids = vec![0, 1];
2103 let vals: Vec<f64> = ids
2104 .iter()
2105 .map(|&id| self.get_plain(id).unwrap_or(0.0))
2106 .collect();
2107 (ids, vals)
2108 }
2109
2110 fn restore_values(&self, values: &[(u32, f64)]) {
2111 for &(id, val) in values {
2112 self.set_plain(id, val);
2113 }
2114 }
2115 }
2116
2117 impl Default for ManyOptionParams {
2118 fn default() -> Self {
2119 Self::new()
2120 }
2121 }
2122
2123 // -- Additional helpers --
2124
2125 /// Build an editor with a dropdown in the last row (near the window bottom).
2126 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
2127 let params = Arc::new(TestParams::new());
2128 // 3 rows of 2, dropdown in the last row (row 2)
2129 let layout = GridLayout::build(vec![widgets(vec![
2130 GridWidget::knob(1u32, "K1"),
2131 GridWidget::knob(1u32, "K2"),
2132 GridWidget::knob(1u32, "K3"),
2133 GridWidget::knob(1u32, "K4"),
2134 GridWidget::dropdown(0u32, "Mode"),
2135 GridWidget::knob(1u32, "K5"),
2136 ])])
2137 .with_cols(2);
2138 let mut editor = BuiltinEditor::new_grid(params, layout);
2139 if let Layout::Grid(ref gl) = editor.layout {
2140 editor.interaction.build_regions_grid(gl);
2141 for (idx, gw) in gl.widgets.iter().enumerate() {
2142 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2143 region.widget_type =
2144 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2145 }
2146 }
2147 }
2148 editor.render();
2149 editor
2150 }
2151
2152 /// Build an editor with two dropdowns side by side.
2153 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
2154 let params = Arc::new(TestParams::new());
2155 let layout = GridLayout::build(vec![widgets(vec![
2156 GridWidget::dropdown(0u32, "Mode A"),
2157 GridWidget::dropdown(0u32, "Mode B"),
2158 ])]);
2159 let mut editor = BuiltinEditor::new_grid(params, layout);
2160 if let Layout::Grid(ref gl) = editor.layout {
2161 editor.interaction.build_regions_grid(gl);
2162 for (idx, gw) in gl.widgets.iter().enumerate() {
2163 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2164 region.widget_type =
2165 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2166 }
2167 }
2168 }
2169 editor.render();
2170 editor
2171 }
2172
2173 /// Build an editor with a 20-option dropdown for scroll testing.
2174 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
2175 let params = Arc::new(ManyOptionParams::new());
2176 let layout = GridLayout::build(vec![widgets(vec![
2177 GridWidget::dropdown(0u32, "Note"),
2178 GridWidget::knob(1u32, "Gain"),
2179 ])]);
2180 let mut editor = BuiltinEditor::new_grid(params, layout);
2181 if let Layout::Grid(ref gl) = editor.layout {
2182 editor.interaction.build_regions_grid(gl);
2183 for (idx, gw) in gl.widgets.iter().enumerate() {
2184 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2185 region.widget_type =
2186 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2187 }
2188 }
2189 }
2190 editor.render();
2191 editor
2192 }
2193
2194 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
2195 let region = editor
2196 .interaction
2197 .knob_regions
2198 .iter()
2199 .find(|r| r.widget_type == WidgetType::Dropdown)
2200 .expect("no dropdown in layout");
2201 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2202 }
2203
2204 // -- Tests: dropdown overflow/clipping --
2205
2206 #[test]
2207 fn dropdown_anchors_below_button_scrolls_when_tight() {
2208 let mut editor = make_editor_bottom_dropdown();
2209 let (dx, dy) = {
2210 let region = editor
2211 .interaction
2212 .knob_regions
2213 .iter()
2214 .find(|r| r.widget_type == WidgetType::Dropdown)
2215 .unwrap();
2216 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2217 };
2218
2219 editor.on_mouse_down(dx, dy);
2220 editor.on_mouse_up(dx, dy);
2221 assert!(editor.interaction.dropdown_is_open());
2222
2223 let dd = editor.interaction.dropdown.as_ref().unwrap();
2224 let region = &editor.interaction.knob_regions[dd.region_idx];
2225 let (_, popup_y, _, popup_h) = dd.popup_rect;
2226 let window_h = editor.layout.height() as f32;
2227
2228 // Popup anchors at the button's bottom - never shifts up
2229 // and never flips above. If the full option list doesn't
2230 // fit between the anchor and the window bottom, the popup
2231 // scrolls instead of relocating away from the tap target.
2232 assert_eq!(
2233 popup_y, region.dropdown_anchor_y,
2234 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2235 );
2236 // Popup never extends past the window bottom.
2237 assert!(
2238 popup_y + popup_h <= window_h + 1.0,
2239 "popup bottom {} exceeds window height {window_h}",
2240 popup_y + popup_h
2241 );
2242 }
2243
2244 #[test]
2245 fn dropdown_clamps_horizontal_near_right_edge() {
2246 let mut editor = make_editor_two_dropdowns();
2247 // The second dropdown is in column 1 (right side)
2248 let region = &editor.interaction.knob_regions[1];
2249 assert_eq!(region.widget_type, WidgetType::Dropdown);
2250 let dx = region.x + region.w / 2.0;
2251 let dy = region.y + region.h / 2.0;
2252
2253 editor.on_mouse_down(dx, dy);
2254 editor.on_mouse_up(dx, dy);
2255 assert!(editor.interaction.dropdown_is_open());
2256
2257 let dd = editor.interaction.dropdown.as_ref().unwrap();
2258 let (popup_x, _, popup_w, _) = dd.popup_rect;
2259 let window_w = editor.layout.width() as f32;
2260
2261 assert!(
2262 popup_x + popup_w <= window_w + 1.0,
2263 "popup right edge {} exceeds window width {window_w}",
2264 popup_x + popup_w
2265 );
2266 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2267 }
2268
2269 #[test]
2270 fn dropdown_scroll_long_list() {
2271 let mut editor = make_editor_many_options();
2272 let (dx, dy) = dropdown_center_many(&editor);
2273
2274 editor.on_mouse_down(dx, dy);
2275 editor.on_mouse_up(dx, dy);
2276 assert!(editor.interaction.dropdown_is_open());
2277
2278 let dd = editor.interaction.dropdown.as_ref().unwrap();
2279 // 20-option enum → step_count = 19 → 19 options
2280 assert!(
2281 dd.options.len() > dd.visible_count,
2282 "expected scroll: {} options, {} visible",
2283 dd.options.len(),
2284 dd.visible_count
2285 );
2286 assert_eq!(dd.scroll_offset, 0);
2287 }
2288
2289 #[test]
2290 fn dropdown_scroll_clamps_to_bounds() {
2291 let mut editor = make_editor_many_options();
2292 let (dx, dy) = dropdown_center_many(&editor);
2293
2294 editor.on_mouse_down(dx, dy);
2295 editor.on_mouse_up(dx, dy);
2296
2297 // Scroll up past the top - should stay at 0
2298 editor.interaction.dropdown_scroll(-10);
2299 assert_eq!(
2300 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2301 0
2302 );
2303
2304 // Scroll down past the bottom - should clamp
2305 editor.interaction.dropdown_scroll(1000);
2306 let dd = editor.interaction.dropdown.as_ref().unwrap();
2307 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2308 assert_eq!(dd.scroll_offset, max_offset);
2309 }
2310
2311 #[test]
2312 fn dropdown_selected_item_visible_on_open() {
2313 let mut editor = make_editor_many_options();
2314 // Set the value to option 15 out of 19 (normalized = 15/18)
2315 editor.params.set_normalized(0, 15.0 / 18.0);
2316
2317 let (dx, dy) = dropdown_center_many(&editor);
2318 editor.on_mouse_down(dx, dy);
2319 editor.on_mouse_up(dx, dy);
2320
2321 let dd = editor.interaction.dropdown.as_ref().unwrap();
2322 let selected = dd.selected;
2323 // The selected item should be within the visible window
2324 assert!(
2325 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2326 "selected={selected} not in visible range {}..{}",
2327 dd.scroll_offset,
2328 dd.scroll_offset + dd.visible_count
2329 );
2330 }
2331
2332 #[test]
2333 fn dropdown_scroll_then_select_correct_index() {
2334 let mut editor = make_editor_many_options();
2335 let (dx, dy) = dropdown_center_many(&editor);
2336
2337 editor.on_mouse_down(dx, dy);
2338 editor.on_mouse_up(dx, dy);
2339
2340 // Scroll down by 3
2341 editor.interaction.dropdown_scroll(3);
2342 assert_eq!(
2343 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2344 3
2345 );
2346
2347 // Click the second visible item (local index 1 → absolute index 4)
2348 let dd = editor.interaction.dropdown.as_ref().unwrap();
2349 let (px, py, _, _) = dd.popup_rect;
2350 let item_h = 18.0f32;
2351 let padding = 4.0f32;
2352 let click_y = py + padding + item_h + item_h / 2.0; // middle of second visible item
2353
2354 editor.on_mouse_down(px + 10.0, click_y);
2355 editor.on_mouse_up(px + 10.0, click_y);
2356
2357 assert!(!editor.interaction.dropdown_is_open());
2358 // Absolute index = scroll_offset(3) + local(1) = 4
2359 // 20 options → norm = 4/19
2360 let norm = editor.params.get_normalized(0).unwrap();
2361 let expected = 4.0 / 19.0;
2362 assert!(
2363 (norm - expected).abs() < 0.01,
2364 "expected {expected:.4}, got {norm:.4}"
2365 );
2366 }
2367
2368 #[test]
2369 fn dropdown_click_different_dropdown_closes_first() {
2370 let mut editor = make_editor_two_dropdowns();
2371 let r0 = &editor.interaction.knob_regions[0];
2372 let r1 = &editor.interaction.knob_regions[1];
2373 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2374 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2375
2376 // Open dropdown A
2377 editor.on_mouse_down(ax, ay);
2378 editor.on_mouse_up(ax, ay);
2379 assert!(editor.interaction.dropdown_is_open());
2380 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2381
2382 // Click dropdown B - should close A and open B
2383 editor.on_mouse_down(bx, by);
2384 editor.on_mouse_up(bx, by);
2385 assert!(editor.interaction.dropdown_is_open());
2386 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2387 }
2388
2389 #[test]
2390 fn dropdown_hover_tracks_correct_option() {
2391 let mut editor = make_editor();
2392 let (dx, dy) = dropdown_center(&editor);
2393
2394 editor.on_mouse_down(dx, dy);
2395 editor.on_mouse_up(dx, dy);
2396
2397 let dd = editor.interaction.dropdown.as_ref().unwrap();
2398 let (px, py, pw, _) = dd.popup_rect;
2399 let item_h = 18.0f32;
2400 let padding = 4.0f32;
2401 let last_visible = dd.visible_count - 1;
2402
2403 // Hover over the last visible item
2404 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2405 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2406
2407 let dd = editor.interaction.dropdown.as_ref().unwrap();
2408 assert_eq!(
2409 dd.hover_option,
2410 Some(last_visible),
2411 "expected hover on last visible option"
2412 );
2413
2414 // Move outside the popup
2415 editor.on_mouse_moved(0.0, 0.0);
2416 let dd = editor.interaction.dropdown.as_ref().unwrap();
2417 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2418 }
2419
2420 #[test]
2421 fn dropdown_popup_within_window_bounds() {
2422 // Verify popup never exceeds window in any direction
2423 let mut editor = make_editor();
2424 let (dx, dy) = dropdown_center(&editor);
2425
2426 editor.on_mouse_down(dx, dy);
2427 editor.on_mouse_up(dx, dy);
2428
2429 let dd = editor.interaction.dropdown.as_ref().unwrap();
2430 let (px, py, pw, ph) = dd.popup_rect;
2431 let window_w = editor.layout.width() as f32;
2432 let window_h = editor.layout.height() as f32;
2433
2434 assert!(px >= 0.0, "popup left edge {px} < 0");
2435 assert!(py >= 0.0, "popup top edge {py} < 0");
2436 assert!(
2437 px + pw <= window_w + 1.0,
2438 "popup right {} > window {window_w}",
2439 px + pw
2440 );
2441 assert!(
2442 py + ph <= window_h + 1.0,
2443 "popup bottom {} > window {window_h}",
2444 py + ph
2445 );
2446 }
2447}