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