Skip to main content

truce_gui_types/
interaction.rs

1//! Mouse interaction for GUI widgets.
2//!
3//! Tracks widget hit regions and maps mouse drags to parameter value changes.
4
5use truce_core::Float;
6use truce_core::cast::{discrete_index, discrete_norm};
7
8use crate::layout::{
9    GRID_GAP, GRID_PADDING, GridLayout, Layout, PluginLayout, ROWS_COLUMN_GAP, ROWS_LAYOUT_TOP,
10    ROWS_ROW_GAP, ROWS_SECTION_LABEL_HEIGHT, WidgetKind, compute_section_offsets,
11};
12use crate::snapshot::ParamSnapshot;
13use crate::widgets::WidgetType;
14
15/// Lower an explicit `WidgetKind` from a layout helper into the
16/// runtime `WidgetType` the interaction code dispatches on. `None`
17/// (meaning "infer from param range") stays as Knob - callers that
18/// need inference overwrite `widget_type` after calling
19/// `build_regions_*`.
20//
21// `Some(Knob) => Knob` and `None => Knob` share a value but mean
22// different things - explicit user-specified Knob vs. an
23// inference-pending placeholder. Keep the arms separate so the
24// distinction is greppable.
25#[allow(clippy::match_same_arms)]
26fn widget_kind_to_type(kind: Option<WidgetKind>) -> WidgetType {
27    match kind {
28        Some(WidgetKind::Knob) => WidgetType::Knob,
29        Some(WidgetKind::Slider) => WidgetType::Slider,
30        Some(WidgetKind::Toggle) => WidgetType::Toggle,
31        Some(WidgetKind::Dropdown) => WidgetType::Dropdown,
32        Some(WidgetKind::Meter) => WidgetType::Meter,
33        Some(WidgetKind::XYPad) => WidgetType::XYPad,
34        None => WidgetType::Knob,
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Platform-agnostic input events + edit outputs
40// ---------------------------------------------------------------------------
41
42/// Which mouse button triggered an event.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum MouseButton {
45    Left,
46    Right,
47    Middle,
48}
49
50/// Keyboard modifier state at event time.
51// Standard four modifier flags - bitflags would just add ceremony.
52#[allow(clippy::struct_excessive_bools)]
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
54pub struct Modifiers {
55    pub shift: bool,
56    pub ctrl: bool,
57    pub alt: bool,
58    pub meta: bool,
59}
60
61/// Platform-agnostic input event consumed by `dispatch`.
62///
63/// Cursor coordinates are in logical pixels, matching what widgets draw at.
64///
65/// `pointer_id` distinguishes simultaneous pointers (multi-touch).
66/// Mouse-driven flows always pass [`SINGLE_POINTER`] (= 0); iOS touch
67/// dispatch uses the `UITouch*` cast to `u64` so each finger gets a
68/// stable identifier across `Down → Move → Up`.
69#[derive(Clone, Copy, Debug)]
70pub enum InputEvent {
71    MouseMove {
72        pointer_id: u64,
73        x: f32,
74        y: f32,
75    },
76    MouseDown {
77        pointer_id: u64,
78        x: f32,
79        y: f32,
80        button: MouseButton,
81    },
82    MouseUp {
83        pointer_id: u64,
84        x: f32,
85        y: f32,
86        button: MouseButton,
87    },
88    /// Synthesized when the host detects a second click within the
89    /// platform-specific threshold. `dispatch` uses this to reset params
90    /// to their defaults.
91    MouseDoubleClick {
92        x: f32,
93        y: f32,
94    },
95    /// Vertical wheel scroll. `dy > 0` = scroll up (away from user),
96    /// `dy < 0` = scroll down. Magnitude is in pixels.
97    Scroll {
98        x: f32,
99        y: f32,
100        dy: f32,
101    },
102    /// The cursor left the editor surface. Dispatch clears hover state.
103    MouseLeave,
104}
105
106/// Single-pointer sentinel for mouse-driven flows. iOS touch
107/// dispatch substitutes the `UITouch*` cast to `u64` so multiple
108/// fingers can drag independently.
109pub const SINGLE_POINTER: u64 = 0;
110
111/// Pixels of vertical drag (or wheel travel) that map to a full
112/// 0.0 → 1.0 normalized parameter range. Shared between knob drag
113/// and the scroll-wheel knob adjustment so the two feel uniform.
114const KNOB_PIXELS_PER_UNIT: f32 = 200.0;
115
116// The `BaseviewTranslator` lives in `truce-gui` (heavy crate) because
117// it depends on `baseview` for windowing-platform event translation.
118// Light backends (truce-egui, truce-iced, truce-slint) don't use it
119// - they translate their own framework's events into `InputEvent`s
120// and call `dispatch` directly.
121
122/// A requested edit to a host parameter, emitted by `dispatch`.
123///
124/// Callers replay these against their host interface:
125/// `Begin → Set* → End` matches the VST3 / CLAP / AU automation protocol.
126#[derive(Clone, Copy, Debug)]
127pub enum ParamEdit {
128    /// Parameter is about to be edited (begin gesture).
129    Begin { id: u32 },
130    /// Set normalized value.
131    Set { id: u32, normalized: f32 },
132    /// Edit gesture finished.
133    End { id: u32 },
134}
135
136/// A widget's hit region on screen.
137#[derive(Clone, Debug)]
138pub struct WidgetRegion {
139    pub param_id: u32,
140    pub widget_type: WidgetType,
141    pub x: f32,
142    pub y: f32,
143    pub w: f32,
144    pub h: f32,
145    /// Center x/y and radius for knob (circular hit test).
146    pub cx: f32,
147    pub cy: f32,
148    pub radius: f32,
149    pub normalized_value: f32,
150    /// Bottom Y of the dropdown button box, set at draw time.
151    /// Used to position the popup directly below the visual button.
152    pub dropdown_anchor_y: f32,
153}
154
155/// State for an open dropdown popup.
156pub struct DropdownState {
157    /// Region index of the dropdown widget that is open.
158    pub region_idx: usize,
159    /// Parameter ID of the open dropdown.
160    pub param_id: u32,
161    /// Popup bounding rect: (x, y, w, h).
162    pub popup_rect: (f32, f32, f32, f32),
163    /// Option labels.
164    pub options: Vec<String>,
165    /// Currently selected index.
166    pub selected: usize,
167    /// Index under the cursor within the popup.
168    pub hover_option: Option<usize>,
169    /// First visible option index (for scrollable popups).
170    pub scroll_offset: usize,
171    /// Number of visible options (may be less than `options.len()` if clamped).
172    pub visible_count: usize,
173}
174
175/// Tracks the current mouse / touch interaction state.
176#[derive(Default)]
177pub struct InteractionState {
178    pub knob_regions: Vec<WidgetRegion>,
179    /// One entry per active pointer (mouse: at most 1; touch: up
180    /// to one per finger). Keyed by `DragState::pointer_id`. Linear
181    /// scan - N is bounded by the device's reported max touches
182    /// (≤10 in practice).
183    pub drags: Vec<DragState>,
184    /// Region index under the cursor (for hover highlight).
185    pub hover_idx: Option<usize>,
186    /// Currently open dropdown popup (at most one at a time).
187    pub dropdown: Option<DropdownState>,
188    /// Active touch-drag on the open dropdown popup - set on
189    /// `MouseDown` inside the popup, updated on `MouseMove`
190    /// (mapping vertical motion to `scroll_offset` change),
191    /// cleared on `MouseUp`. iOS pattern: tap to select, swipe to
192    /// scroll. Desktop scroll-wheel handling stays through the
193    /// `Scroll` event.
194    pub popup_drag: Option<PopupDrag>,
195    /// Set by event handlers whose visible side effect isn't otherwise
196    /// observable to `dispatch_events` (e.g. `MouseLeave` clearing
197    /// hover state). The editor reads this via `take_repaint_request`
198    /// to avoid relying on diff-checks of every individual visible
199    /// field.
200    needs_repaint: bool,
201}
202
203/// Active touch-drag on the open dropdown popup.
204pub struct PopupDrag {
205    pub pointer_id: u64,
206    pub start_y: f32,
207    pub start_scroll_offset: usize,
208    /// True once the user has moved more than `ITEM_H / 2` from
209    /// `start_y`. Distinguishes a tap (select on release) from a
210    /// scroll-drag (keep popup open on release).
211    pub scrolled: bool,
212}
213
214pub struct DragState {
215    /// Identifier of the pointer (mouse or touch) driving this drag.
216    /// See [`SINGLE_POINTER`].
217    pub pointer_id: u64,
218    pub region_idx: usize,
219    pub param_id: u32,
220    pub start_value: f64,
221    pub start_y: f32,
222    pub widget_type: WidgetType,
223    pub region_x: f32,
224    pub region_y: f32,
225    pub region_w: f32,
226    pub region_h: f32,
227}
228
229impl InteractionState {
230    /// Read and clear the explicit repaint flag set by event handlers.
231    pub fn take_repaint_request(&mut self) -> bool {
232        std::mem::replace(&mut self.needs_repaint, false)
233    }
234
235    /// Rebuild hit regions from the layout. Call after render.
236    // Layout col counts widen `u32 as f32`; column counts are
237    // bounded by the editor's row width.
238    #[allow(clippy::cast_precision_loss)]
239    pub fn build_regions(&mut self, layout: &PluginLayout) {
240        // `dropdown_anchor_y` is filled in by the draw pass, not here.
241        // `update_interaction` rebuilds regions every frame, but the
242        // render that repopulates the anchor can be skipped (the macOS
243        // CPU path gates `render` behind a repaint check). Carry prior
244        // anchors over by index so an idle, non-rendering frame doesn't
245        // reset them to 0 and strand the next dropdown popup at the top
246        // of the window.
247        let prior_anchors: Vec<f32> = self
248            .knob_regions
249            .iter()
250            .map(|r| r.dropdown_anchor_y)
251            .collect();
252        self.knob_regions.clear();
253
254        let knob_size = layout.knob_size;
255        let pitch = knob_size + ROWS_COLUMN_GAP;
256        let mut y = ROWS_LAYOUT_TOP;
257
258        for row in &layout.rows {
259            if row.label.is_some() {
260                y += ROWS_SECTION_LABEL_HEIGHT;
261            }
262
263            let total_cols: u32 = row.knobs.iter().map(|k| k.span.max(1)).sum();
264            let total_w = total_cols as f32 * pitch - ROWS_COLUMN_GAP;
265            let start_x = (layout.width as f32 - total_w) / 2.0;
266
267            let mut col = 0u32;
268            for knob_def in &row.knobs {
269                let span = knob_def.span.max(1);
270                let x = start_x + col as f32 * pitch;
271                let widget_w = span as f32 * pitch - ROWS_COLUMN_GAP;
272                let cx = x + widget_w / 2.0;
273                let cy = y + knob_size / 2.0 - 5.0;
274                let radius = knob_size / 2.0 - 4.0;
275
276                let idx = self.knob_regions.len();
277                self.knob_regions.push(WidgetRegion {
278                    param_id: knob_def.param_id,
279                    widget_type: widget_kind_to_type(knob_def.widget),
280                    x,
281                    y,
282                    w: widget_w,
283                    h: knob_size,
284                    cx,
285                    cy,
286                    radius,
287                    normalized_value: 0.0,
288                    dropdown_anchor_y: prior_anchors.get(idx).copied().unwrap_or(0.0),
289                });
290                col += span;
291            }
292
293            y += knob_size + ROWS_ROW_GAP;
294        }
295    }
296
297    /// Check if a mouse position hits a widget. Returns the region index if so.
298    #[must_use]
299    pub fn hit_test(&self, mx: f32, my: f32) -> Option<usize> {
300        for (idx, region) in self.knob_regions.iter().enumerate() {
301            match region.widget_type {
302                WidgetType::Knob => {
303                    let dx = mx - region.cx;
304                    let dy = my - region.cy;
305                    if dx * dx + dy * dy <= region.radius * region.radius {
306                        return Some(idx);
307                    }
308                }
309                WidgetType::Meter => {}
310                WidgetType::Slider
311                | WidgetType::Toggle
312                | WidgetType::Dropdown
313                | WidgetType::XYPad => {
314                    if mx >= region.x
315                        && mx <= region.x + region.w
316                        && my >= region.y
317                        && my <= region.y + region.h
318                    {
319                        return Some(idx);
320                    }
321                }
322            }
323        }
324        None
325    }
326
327    /// Get the widget type by region index.
328    #[must_use]
329    pub fn widget_type_at(&self, idx: usize) -> Option<WidgetType> {
330        self.knob_regions.get(idx).map(|r| r.widget_type)
331    }
332
333    /// Get the region by index.
334    #[must_use]
335    pub fn region_at(&self, idx: usize) -> Option<&WidgetRegion> {
336        self.knob_regions.get(idx)
337    }
338
339    /// Begin a drag on a widget by region index. Returns any prior
340    /// drag for the same `pointer_id` so the caller can emit a
341    /// matching `ParamEdit::End` for it - without this, hosts that
342    /// model gestures as a Begin/End stack (VST3, CLAP, AU on iOS)
343    /// see a stranded Begin and report the param as permanently
344    /// "being touched". iOS reliably triggers this when a system
345    /// gesture recognizer (Control Center swipe, multitasking
346    /// gesture) steals a touch without firing `touchesCancelled:`;
347    /// the next `touchesBegan:` may reuse the same `UITouch*`
348    /// pointer for a different finger.
349    #[must_use]
350    pub fn begin_drag(
351        &mut self,
352        pointer_id: u64,
353        idx: usize,
354        current_normalized: f64,
355        mouse_y: f32,
356    ) -> Option<DragState> {
357        let region = self.knob_regions.get(idx)?;
358        let param_id = region.param_id;
359        let wtype = region.widget_type;
360        let stranded = self
361            .drags
362            .iter()
363            .position(|d| d.pointer_id == pointer_id)
364            .map(|i| self.drags.swap_remove(i));
365        self.drags.push(DragState {
366            pointer_id,
367            region_idx: idx,
368            param_id,
369            start_value: current_normalized,
370            start_y: mouse_y,
371            widget_type: wtype,
372            region_x: region.x,
373            region_y: region.y,
374            region_w: region.w,
375            region_h: region.h,
376        });
377        stranded
378    }
379
380    /// Find the drag for a pointer (read-only).
381    #[must_use]
382    pub fn drag_for(&self, pointer_id: u64) -> Option<&DragState> {
383        self.drags.iter().find(|d| d.pointer_id == pointer_id)
384    }
385
386    /// Update a single drag's knob value (vertical-drag widgets).
387    /// Returns the new (`param_id`, normalized value) for the drag
388    /// matching `pointer_id`, or `None` if no such drag is active.
389    #[must_use]
390    pub fn update_drag(&self, pointer_id: u64, mouse_y: f32) -> Option<(u32, f64)> {
391        let drag = self.drag_for(pointer_id)?;
392        let dy = drag.start_y - mouse_y;
393        let delta = f64::from(dy) / f64::from(KNOB_PIXELS_PER_UNIT);
394        let new_value = (drag.start_value + delta).clamp(0.0, 1.0);
395        Some((drag.param_id, new_value))
396    }
397
398    /// Update a single horizontal-slider drag. Same shape as
399    /// [`InteractionState::update_drag`] but maps `x` rather than `y`.
400    #[must_use]
401    pub fn update_slider_drag(&self, pointer_id: u64, mouse_x: f32) -> Option<(u32, f64)> {
402        let drag = self.drag_for(pointer_id)?;
403        let margin = 4.0;
404        let rel = (mouse_x - drag.region_x - margin) / (drag.region_w - margin * 2.0);
405        let new_value = f64::from(rel).clamp(0.0, 1.0);
406        Some((drag.param_id, new_value))
407    }
408
409    /// End the drag for `pointer_id`. Returns the popped state so
410    /// callers can emit the `ParamEdit::End` (and the y-axis `End`
411    /// on XY pads) without re-searching the vec.
412    pub fn end_drag(&mut self, pointer_id: u64) -> Option<DragState> {
413        let idx = self.drags.iter().position(|d| d.pointer_id == pointer_id)?;
414        Some(self.drags.swap_remove(idx))
415    }
416
417    /// Test if a point is inside the open dropdown popup.
418    /// Returns the absolute option index (accounting for scroll) if hit, or None.
419    #[must_use]
420    // Hit-test math operates on f32 logical pixels bounded by the
421    // window size; `(my - py - padding) / item_h` lands in
422    // `[0, visible_count]`.
423    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
424    pub fn dropdown_popup_hit(&self, mx: f32, my: f32) -> Option<usize> {
425        let dd = self.dropdown.as_ref()?;
426        let (px, py, pw, ph) = dd.popup_rect;
427        if mx < px || mx > px + pw || my < py || my > py + ph {
428            return None;
429        }
430        let item_h = 18.0f32;
431        let padding = 4.0f32;
432        let local_idx = ((my - py - padding) / item_h) as usize;
433        let abs_idx = dd.scroll_offset + local_idx;
434        if abs_idx < dd.options.len() && local_idx < dd.visible_count {
435            Some(abs_idx)
436        } else {
437            None
438        }
439    }
440
441    /// Update the hovered option in the open dropdown popup.
442    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
443    pub fn dropdown_update_hover(&mut self, mx: f32, my: f32) {
444        let mut changed = false;
445        if let Some(ref mut dd) = self.dropdown {
446            let (px, py, pw, ph) = dd.popup_rect;
447            let new_hover = if mx >= px && mx <= px + pw && my >= py && my <= py + ph {
448                let item_h = 18.0f32;
449                let padding = 4.0f32;
450                let local_idx = ((my - py - padding) / item_h) as usize;
451                let abs_idx = dd.scroll_offset + local_idx;
452                if abs_idx < dd.options.len() && local_idx < dd.visible_count {
453                    Some(abs_idx)
454                } else {
455                    None
456                }
457            } else {
458                None
459            };
460            if new_hover != dd.hover_option {
461                dd.hover_option = new_hover;
462                changed = true;
463            }
464        }
465        // `hover_option` is internal to `DropdownState` - the editor's
466        // repaint gate only watches `hover_idx` (the widget-level
467        // hover) and the open/closed transition, so without this flag
468        // the popup only re-rasterizes when the mouse incidentally
469        // trips one of those triggers.
470        if changed {
471            self.needs_repaint = true;
472        }
473    }
474
475    /// Whether a dropdown popup is currently open.
476    #[must_use]
477    pub fn dropdown_is_open(&self) -> bool {
478        self.dropdown.is_some()
479    }
480
481    /// Close the dropdown popup. Returns the region index of the
482    /// dropdown that was open, so the caller can suppress an
483    /// immediate-reopen click landing on the same button without
484    /// having to read `self.dropdown` *before* closing.
485    pub fn dropdown_close(&mut self) -> Option<usize> {
486        let closed = self.dropdown.take().map(|dd| dd.region_idx);
487        if closed.is_some() {
488            // The editor's repaint gate watches `dropdown_is_open()`,
489            // which stays `true` across an A->B switch (close-then-open
490            // inside one event). Flag the dirty bit explicitly so the
491            // CPU renderer repaints when only the popup identity
492            // changes.
493            self.needs_repaint = true;
494        }
495        closed
496    }
497
498    /// Scroll the dropdown popup by `delta` items (positive = down, negative = up).
499    // Dropdown option counts stay below i32::MAX in practice (UI lists
500    // never reach 2 billion).
501    #[allow(
502        clippy::cast_possible_truncation,
503        clippy::cast_possible_wrap,
504        clippy::cast_sign_loss
505    )]
506    pub fn dropdown_scroll(&mut self, delta: i32) {
507        let mut changed = false;
508        if let Some(ref mut dd) = self.dropdown {
509            let max_offset = dd.options.len().saturating_sub(dd.visible_count);
510            let new_offset = (dd.scroll_offset as i32 + delta).clamp(0, max_offset as i32) as usize;
511            if new_offset != dd.scroll_offset {
512                dd.scroll_offset = new_offset;
513                changed = true;
514            }
515        }
516        // The CPU render gate consults `take_repaint_request`; without
517        // this flag a wheel-scroll updates state silently and the
518        // popup looks frozen until an unrelated event flips the bit.
519        // GPU path repaints every frame and doesn't depend on it.
520        if changed {
521            self.needs_repaint = true;
522        }
523    }
524
525    /// Rebuild hit regions from either layout variant.
526    pub fn build_regions_any(&mut self, layout: &Layout) {
527        match layout {
528            Layout::Rows(pl) => self.build_regions(pl),
529            Layout::Grid(gl) => self.build_regions_grid(gl),
530        }
531    }
532
533    /// Rebuild hit regions from a grid layout.
534    //
535    // Grid cell coordinates widen `u32 as f32`; cells indices fit in
536    // an editor's logical pixel range.
537    #[allow(clippy::cast_precision_loss)]
538    pub fn build_regions_grid(&mut self, layout: &GridLayout) {
539        // See `build_regions`: preserve `dropdown_anchor_y` across the
540        // per-frame rebuild so an idle frame that skips render doesn't
541        // strand the next dropdown popup at y = 0.
542        let prior_anchors: Vec<f32> = self
543            .knob_regions
544            .iter()
545            .map(|r| r.dropdown_anchor_y)
546            .collect();
547        self.knob_regions.clear();
548
549        let header_h = layout.header_height();
550        let section_offsets = compute_section_offsets(layout);
551
552        for gw in &layout.widgets {
553            let x = GRID_PADDING + gw.col as f32 * (layout.cell_size + GRID_GAP);
554            let y = header_h
555                + GRID_PADDING
556                + gw.row as f32 * (layout.cell_size + GRID_GAP)
557                + section_offsets[gw.row as usize];
558            let w = gw.col_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
559            let h = gw.row_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
560            let cx = x + w / 2.0;
561            let cy = y + h / 2.0 - 5.0;
562            let radius = w.min(h) / 2.0 - 4.0;
563
564            // Pre-populate widget_type from the explicit `widget` kind
565            // when the layout declares one. Callers that need
566            // range-based inference for `None` (BuiltinEditor) still
567            // overwrite this field after the call; for custom editors
568            // that always set `widget` via the `layout::dropdown` /
569            // `layout::knob` / … helpers, this means dispatch routes
570            // correctly out of the box.
571            let widget_type = widget_kind_to_type(gw.widget);
572
573            let idx = self.knob_regions.len();
574            self.knob_regions.push(WidgetRegion {
575                param_id: gw.param_id,
576                widget_type,
577                x,
578                y,
579                w,
580                h,
581                cx,
582                cy,
583                radius,
584                normalized_value: 0.0,
585                dropdown_anchor_y: prior_anchors.get(idx).copied().unwrap_or(0.0),
586            });
587        }
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Public `dispatch` - drive widget interactions from input events.
593// ---------------------------------------------------------------------------
594
595/// Route a batch of input events through the widget tree, updating
596/// `state` in place (hover, drag origins, dropdown open/closed, …) and
597/// returning the sequence of parameter edits they imply.
598///
599/// `state.knob_regions` must be up to date for the current layout; callers
600/// typically call `state.build_regions_any(layout)` once after a layout
601/// change. `snapshot` provides read access to live parameter values.
602///
603/// This does NOT mutate any parameter store. Callers replay the returned
604/// `ParamEdit`s against their host interface.
605pub fn dispatch(
606    events: &[InputEvent],
607    layout: &Layout,
608    snapshot: &ParamSnapshot<'_>,
609    state: &mut InteractionState,
610) -> Vec<ParamEdit> {
611    let (w, h) = (layout.width(), layout.height());
612    dispatch_in(events, layout, (w, h), snapshot, state)
613}
614
615/// Like [`dispatch`] but takes explicit `window_size` in the same
616/// coordinate space as the layout - i.e. the size of the surface the
617/// layout is being composited onto.
618///
619/// Use this when the layout is a chrome panel overlaid on a larger
620/// custom-rendered surface (visualizers, graphs, canvases). It lets
621/// dropdown popups and other bounds-aware overlays use the full
622/// window rather than being clipped to the layout's bounding box -
623/// otherwise a popup that wouldn't fit below the button flips above
624/// it even when there's room below in the outer window.
625// Window dimensions widen `u32 as f32`; window sizes are bounded by
626// display dimensions, well below 2^23.
627#[allow(clippy::cast_precision_loss)]
628pub fn dispatch_in(
629    events: &[InputEvent],
630    layout: &Layout,
631    window_size: (u32, u32),
632    snapshot: &ParamSnapshot<'_>,
633    state: &mut InteractionState,
634) -> Vec<ParamEdit> {
635    let mut edits = Vec::new();
636    let window_w = window_size.0 as f32;
637    let window_h = window_size.1 as f32;
638
639    for ev in events {
640        match *ev {
641            InputEvent::MouseMove { pointer_id, x, y } => {
642                // Popup-drag wins over knob-drag - a finger that
643                // landed inside the open popup scrolls the list,
644                // not any widget under it.
645                if let Some(drag) = state.popup_drag.as_ref()
646                    && drag.pointer_id == pointer_id
647                {
648                    apply_popup_scroll_drag(y, state);
649                    continue;
650                }
651                let drag_info = state
652                    .drag_for(pointer_id)
653                    .map(|d| (d.widget_type, d.region_idx));
654                if let Some((wtype, region_idx)) = drag_info {
655                    let y_id = if wtype == WidgetType::XYPad {
656                        layout_param_id_y(layout, region_idx)
657                    } else {
658                        None
659                    };
660                    apply_drag(pointer_id, x, y, y_id, snapshot, state, &mut edits);
661                } else {
662                    // Hover / dropdown-hover are single-cursor concepts;
663                    // skip for genuine multi-touch pointers so a second
664                    // finger landing doesn't yank hover state away from
665                    // the cursor's last position on a hybrid Mac.
666                    if pointer_id == SINGLE_POINTER {
667                        if state.dropdown_is_open() {
668                            state.dropdown_update_hover(x, y);
669                        }
670                        state.hover_idx = state.hit_test(x, y);
671                    }
672                }
673            }
674            InputEvent::MouseDown {
675                pointer_id,
676                x,
677                y,
678                button: MouseButton::Left,
679            } => {
680                handle_mouse_down(
681                    pointer_id, x, y, layout, snapshot, state, window_w, window_h, &mut edits,
682                );
683            }
684            InputEvent::MouseUp {
685                pointer_id,
686                x,
687                y,
688                button: MouseButton::Left,
689            } => {
690                // Popup-drag end: if the user didn't scroll
691                // appreciably (stayed within `ITEM_H / 2` of the
692                // start), treat the touch as a tap and commit the
693                // option under the release point. If they did
694                // scroll, just keep the popup open.
695                if let Some(drag) = state.popup_drag.take()
696                    && drag.pointer_id == pointer_id
697                {
698                    if !drag.scrolled
699                        && let Some(option_idx) = state.dropdown_popup_hit(x, y)
700                        && let Some(dd) = state.dropdown.as_ref()
701                    {
702                        let param_id = dd.param_id;
703                        let count = dd.options.len();
704                        let new_norm = f32::from_f64(discrete_norm(option_idx, count));
705                        edits.push(ParamEdit::Begin { id: param_id });
706                        edits.push(ParamEdit::Set {
707                            id: param_id,
708                            normalized: new_norm,
709                        });
710                        edits.push(ParamEdit::End { id: param_id });
711                        state.dropdown_close();
712                    }
713                    continue;
714                }
715                if let Some(drag) = state.end_drag(pointer_id) {
716                    edits.push(ParamEdit::End { id: drag.param_id });
717                    if drag.widget_type == WidgetType::XYPad
718                        && let Some(y_id) = layout_param_id_y(layout, drag.region_idx)
719                    {
720                        edits.push(ParamEdit::End { id: y_id });
721                    }
722                }
723            }
724            InputEvent::MouseDoubleClick { x, y } => {
725                if let Some(idx) = state.hit_test(x, y) {
726                    let param_id = state.knob_regions[idx].param_id;
727                    let default_norm = (snapshot.default_normalized)(param_id);
728                    edits.push(ParamEdit::Begin { id: param_id });
729                    edits.push(ParamEdit::Set {
730                        id: param_id,
731                        normalized: default_norm,
732                    });
733                    edits.push(ParamEdit::End { id: param_id });
734                }
735            }
736            InputEvent::Scroll { x, y, dy } => {
737                if state.dropdown_is_open() {
738                    // An open dropdown captures ALL scroll input: wheel
739                    // inside the popup scrolls the list, wheel outside
740                    // is absorbed (no-op) so it can't fall through to
741                    // the generic knob-scroll path below and silently
742                    // advance the param driving this very dropdown.
743                    let inside_popup = state.dropdown_popup_hit(x, y).is_some()
744                        || state.dropdown.as_ref().is_some_and(|dd| {
745                            let (px, py, pw, ph) = dd.popup_rect;
746                            x >= px && x <= px + pw && y >= py && y <= py + ph
747                        });
748                    if inside_popup {
749                        // dy == 0 should be a no-op - falling through to
750                        // the else branch would silently scroll +1 each
751                        // time a host emits a zero-magnitude wheel event.
752                        let delta = match dy.partial_cmp(&0.0) {
753                            Some(std::cmp::Ordering::Greater) => -1,
754                            Some(std::cmp::Ordering::Less) => 1,
755                            _ => 0,
756                        };
757                        if delta != 0 {
758                            state.dropdown_scroll(delta);
759                        }
760                    }
761                    continue;
762                }
763                if let Some(idx) = state.hit_test(x, y) {
764                    // Only scroll-adjust continuous-value widgets.
765                    // Dropdowns / Toggles are discrete UI affordances -
766                    // the user expects click to open or toggle, not
767                    // wheel to drag them across their whole range.
768                    let wtype = state.knob_regions[idx].widget_type;
769                    if matches!(
770                        wtype,
771                        WidgetType::Knob | WidgetType::Slider | WidgetType::XYPad,
772                    ) {
773                        let param_id = state.knob_regions[idx].param_id;
774                        let norm = (snapshot.get_param)(param_id);
775                        let step = dy / KNOB_PIXELS_PER_UNIT;
776                        let new_norm =
777                            (snapshot.snap_normalized)(param_id, (norm + step).clamp(0.0, 1.0));
778                        edits.push(ParamEdit::Begin { id: param_id });
779                        edits.push(ParamEdit::Set {
780                            id: param_id,
781                            normalized: new_norm,
782                        });
783                        edits.push(ParamEdit::End { id: param_id });
784                    }
785                }
786            }
787            InputEvent::MouseLeave => {
788                if state.hover_idx.is_some() {
789                    state.hover_idx = None;
790                    state.needs_repaint = true;
791                }
792            }
793            // Right- and middle-click are intentionally ignored. The
794            // built-in editor doesn't have a context menu of its own,
795            // and most plugin hosts (VST3, AU, AAX) treat right-click
796            // inside the editor surface as their hook for the host's
797            // own automation / parameter-link menu - swallowing the
798            // event here would suppress that.
799            InputEvent::MouseDown { .. } | InputEvent::MouseUp { .. } => {}
800        }
801    }
802
803    edits
804}
805
806/// Mouse-down handling factored out of the big match so it's readable.
807fn handle_mouse_down(
808    pointer_id: u64,
809    x: f32,
810    y: f32,
811    layout: &Layout,
812    snapshot: &ParamSnapshot<'_>,
813    state: &mut InteractionState,
814    window_w: f32,
815    window_h: f32,
816    edits: &mut Vec<ParamEdit>,
817) {
818    // If a dropdown popup is open, handle it first.
819    if let Some(dd) = state.dropdown.as_ref() {
820        // MouseDown inside the popup starts a touch-drag - the
821        // commit-or-scroll decision is deferred to MouseUp based
822        // on whether the user moved or stayed still. Without
823        // this, every tap on the popup commits immediately and
824        // there's no way for touch users to scroll a list longer
825        // than the visible area.
826        let (px, py, pw, ph) = dd.popup_rect;
827        if x >= px && x <= px + pw && y >= py && y <= py + ph {
828            state.popup_drag = Some(PopupDrag {
829                pointer_id,
830                start_y: y,
831                start_scroll_offset: dd.scroll_offset,
832                scrolled: false,
833            });
834            return;
835        }
836        // Click outside popup: close. If it landed on the same dropdown
837        // button, swallow the click (don't reopen).
838        if let Some(open_region) = state.dropdown_close()
839            && let Some(idx) = state.hit_test(x, y)
840            && idx == open_region
841            && state.widget_type_at(idx) == Some(WidgetType::Dropdown)
842        {
843            return;
844        }
845        // Fall through to normal widget hit-test.
846    }
847
848    let Some(idx) = state.hit_test(x, y) else {
849        return;
850    };
851    let param_id = state.knob_regions[idx].param_id;
852    let wtype = state.widget_type_at(idx);
853
854    match wtype {
855        Some(WidgetType::Toggle) => {
856            let norm = (snapshot.get_param)(param_id);
857            let new_norm = if norm > 0.5 { 0.0 } else { 1.0 };
858            edits.push(ParamEdit::Begin { id: param_id });
859            edits.push(ParamEdit::Set {
860                id: param_id,
861                normalized: new_norm,
862            });
863            edits.push(ParamEdit::End { id: param_id });
864        }
865        Some(WidgetType::Dropdown) => {
866            open_dropdown(idx, param_id, snapshot, state, window_w, window_h);
867        }
868        _ => {
869            // Knob / Slider / XYPad / Meter: begin a drag.
870            let norm = f64::from((snapshot.get_param)(param_id));
871            // If a system gesture stole the previous touch for this
872            // pointer_id without firing `touchesCancelled:`, the
873            // displaced drag's `Begin` is still on the host's
874            // gesture stack - flush an `End` for it (XY pads need
875            // both axes) before opening the new gesture.
876            if let Some(stranded) = state.begin_drag(pointer_id, idx, norm, y) {
877                edits.push(ParamEdit::End {
878                    id: stranded.param_id,
879                });
880                if stranded.widget_type == WidgetType::XYPad
881                    && let Some(y_id) = layout_param_id_y(layout, stranded.region_idx)
882                {
883                    edits.push(ParamEdit::End { id: y_id });
884                }
885            }
886            edits.push(ParamEdit::Begin { id: param_id });
887            if wtype == Some(WidgetType::XYPad)
888                && let Some(y_id) = layout_param_id_y(layout, idx)
889            {
890                edits.push(ParamEdit::Begin { id: y_id });
891            }
892        }
893    }
894}
895
896// Layout / hit-test math is f32 logical pixels bounded by window size;
897// `((avail_h - padding * 2.0) / item_h)` lands in `[0, options.len()]`.
898#[allow(
899    clippy::cast_possible_truncation,
900    clippy::cast_sign_loss,
901    clippy::cast_precision_loss
902)]
903fn open_dropdown(
904    region_idx: usize,
905    param_id: u32,
906    snapshot: &ParamSnapshot<'_>,
907    state: &mut InteractionState,
908    window_w: f32,
909    window_h: f32,
910) {
911    let options = (snapshot.get_options)(param_id);
912    if options.is_empty() {
913        return;
914    }
915    let count = options.len();
916    let current_norm = (snapshot.get_param)(param_id);
917    let selected = discrete_index(f64::from(current_norm), count);
918    let region = &state.knob_regions[region_idx];
919
920    let item_h = 18.0f32;
921    let padding = 4.0f32;
922
923    let anchor_below = region.dropdown_anchor_y; // bottom of button box
924    let popup_w = region.w.max(80.0);
925    let full_popup_h = options.len() as f32 * item_h + padding * 2.0;
926
927    // Always anchor the popup directly under the dropdown button.
928    // If the full list doesn't fit between `anchor_below` and the
929    // window's bottom, cap `visible_count` and scroll - DON'T
930    // shift the popup upward to make more items fit. Shifting up
931    // landed the popup near `y = 0` (literally the top of the
932    // editor) for any dropdown whose full option list was taller
933    // than the editor, far from the button the user just tapped.
934    // Scrolling is the lesser annoyance.
935    let popup_y = anchor_below.max(0.0);
936    let space_below = (window_h - popup_y).max(item_h + padding * 2.0);
937    let avail_h = full_popup_h.min(space_below);
938
939    let visible_count = ((avail_h - padding * 2.0) / item_h).floor().max(1.0) as usize;
940    let visible_count = visible_count.min(options.len());
941    let popup_h = visible_count as f32 * item_h + padding * 2.0;
942
943    let popup_x = region.x.clamp(0.0, (window_w - popup_w).max(0.0));
944    let scroll_offset = if selected >= visible_count {
945        selected - visible_count + 1
946    } else {
947        0
948    };
949
950    state.dropdown = Some(DropdownState {
951        region_idx,
952        param_id,
953        popup_rect: (popup_x, popup_y, popup_w, popup_h),
954        options,
955        selected,
956        hover_option: None,
957        scroll_offset,
958        visible_count,
959    });
960    // Matches `dropdown_close`: flag the dirty bit so the popup paints
961    // even if the editor's repaint gate only saw `dropdown_is_open()`
962    // toggle on (it diff-checks open/closed but not popup identity).
963    state.needs_repaint = true;
964}
965
966/// Touch scroll-drag on the open dropdown popup. Maps vertical
967/// motion since the drag started into `scroll_offset` changes
968/// (one item per `item_h` of drag). If the user has moved more
969/// than half an item from the start, flips `scrolled = true` so
970/// the `MouseUp` handler treats the touch as a scroll instead of
971/// a commit-on-tap.
972//
973// Cast contract: `start_scroll_offset` is bounded by
974// `dd.options.len()` which (per the dropdown widget shape) caps
975// at a few hundred - well below `i32::MAX`. `items_scrolled` is
976// `(dy / item_h)` where `dy` is a finite single-frame motion;
977// the product never approaches i32 limits.
978#[allow(
979    clippy::cast_possible_truncation,
980    clippy::cast_sign_loss,
981    clippy::cast_possible_wrap
982)]
983fn apply_popup_scroll_drag(y: f32, state: &mut InteractionState) {
984    let item_h = 18.0f32;
985    let (start_y, start_scroll_offset) = match state.popup_drag.as_ref() {
986        Some(d) => (d.start_y, d.start_scroll_offset),
987        None => return,
988    };
989    let dy = start_y - y;
990    if dy.abs() > item_h / 2.0
991        && let Some(d) = state.popup_drag.as_mut()
992    {
993        d.scrolled = true;
994    }
995    let items_scrolled = (dy / item_h).round() as i32;
996    let new_offset = start_scroll_offset as i32 + items_scrolled;
997    let mut changed = false;
998    if let Some(dd) = state.dropdown.as_mut() {
999        let max_offset = (dd.options.len() as i32 - dd.visible_count as i32).max(0);
1000        let clamped = new_offset.clamp(0, max_offset) as usize;
1001        if clamped != dd.scroll_offset {
1002            dd.scroll_offset = clamped;
1003            changed = true;
1004        }
1005    }
1006    if changed {
1007        state.needs_repaint = true;
1008    }
1009}
1010
1011fn apply_drag(
1012    pointer_id: u64,
1013    x: f32,
1014    y: f32,
1015    y_id_for_xy: Option<u32>,
1016    snapshot: &ParamSnapshot<'_>,
1017    state: &InteractionState,
1018    edits: &mut Vec<ParamEdit>,
1019) {
1020    let Some(drag) = state.drag_for(pointer_id) else {
1021        return;
1022    };
1023    // `snap_normalized` rounds to integer steps for `Discrete` / `Enum`
1024    // ranges; continuous params round-trip the identity. Without it
1025    // the editor's writeback path snaps in storage but the emitted
1026    // edit still carries the unsnapped value, which left mid-drag UI
1027    // and audio reads out of phase.
1028    let snap = |id, n: f32| (snapshot.snap_normalized)(id, n);
1029    match drag.widget_type {
1030        WidgetType::XYPad => {
1031            // Geometry must match `draw_xy_pad` exactly or the dot
1032            // tracks the cursor at the wrong pace. The X axis label is
1033            // drawn *below* the widget (outside `region_h`), so the pad
1034            // fills the full height minus margins - no label reserve.
1035            let pad_margin = 4.0;
1036            let pad_x = drag.region_x + pad_margin;
1037            let pad_w = drag.region_w - pad_margin * 2.0;
1038            let pad_y_start = drag.region_y + pad_margin;
1039            let pad_h = drag.region_h - pad_margin * 2.0;
1040
1041            let norm_x = ((x - pad_x) / pad_w).clamp(0.0, 1.0);
1042            let norm_y = (1.0 - (y - pad_y_start) / pad_h).clamp(0.0, 1.0);
1043
1044            edits.push(ParamEdit::Set {
1045                id: drag.param_id,
1046                normalized: snap(drag.param_id, norm_x),
1047            });
1048            if let Some(y_id) = y_id_for_xy {
1049                edits.push(ParamEdit::Set {
1050                    id: y_id,
1051                    normalized: snap(y_id, norm_y),
1052                });
1053            }
1054        }
1055        WidgetType::Slider => {
1056            if let Some((pid, new_norm)) = state.update_slider_drag(pointer_id, x) {
1057                edits.push(ParamEdit::Set {
1058                    id: pid,
1059                    normalized: snap(pid, f32::from_f64(new_norm)),
1060                });
1061            }
1062        }
1063        _ => {
1064            if let Some((pid, new_norm)) = state.update_drag(pointer_id, y) {
1065                edits.push(ParamEdit::Set {
1066                    id: pid,
1067                    normalized: snap(pid, f32::from_f64(new_norm)),
1068                });
1069            }
1070        }
1071    }
1072}
1073
1074/// Look up the Y-axis parameter ID for a widget at `region_idx` in the layout.
1075/// Returns `None` if the widget is not an XY pad (or the index is invalid).
1076pub(crate) fn layout_param_id_y(layout: &Layout, region_idx: usize) -> Option<u32> {
1077    match layout {
1078        Layout::Rows(pl) => {
1079            let mut i = 0;
1080            for row in &pl.rows {
1081                for kd in &row.knobs {
1082                    if i == region_idx {
1083                        return kd.param_id_y;
1084                    }
1085                    i += 1;
1086                }
1087            }
1088            None
1089        }
1090        Layout::Grid(g) => g.widgets.get(region_idx).and_then(|w| w.param_id_y),
1091    }
1092}