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 self.dropdown.take().map(|dd| dd.region_idx)
489 }
490
491 /// Scroll the dropdown popup by `delta` items (positive = down, negative = up).
492 // Dropdown option counts stay below i32::MAX in practice (UI lists
493 // never reach 2 billion).
494 #[allow(
495 clippy::cast_possible_truncation,
496 clippy::cast_possible_wrap,
497 clippy::cast_sign_loss
498 )]
499 pub fn dropdown_scroll(&mut self, delta: i32) {
500 let mut changed = false;
501 if let Some(ref mut dd) = self.dropdown {
502 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
503 let new_offset = (dd.scroll_offset as i32 + delta).clamp(0, max_offset as i32) as usize;
504 if new_offset != dd.scroll_offset {
505 dd.scroll_offset = new_offset;
506 changed = true;
507 }
508 }
509 // The CPU render gate consults `take_repaint_request`; without
510 // this flag a wheel-scroll updates state silently and the
511 // popup looks frozen until an unrelated event flips the bit.
512 // GPU path repaints every frame and doesn't depend on it.
513 if changed {
514 self.needs_repaint = true;
515 }
516 }
517
518 /// Rebuild hit regions from either layout variant.
519 pub fn build_regions_any(&mut self, layout: &Layout) {
520 match layout {
521 Layout::Rows(pl) => self.build_regions(pl),
522 Layout::Grid(gl) => self.build_regions_grid(gl),
523 }
524 }
525
526 /// Rebuild hit regions from a grid layout.
527 //
528 // Grid cell coordinates widen `u32 as f32`; cells indices fit in
529 // an editor's logical pixel range.
530 #[allow(clippy::cast_precision_loss)]
531 pub fn build_regions_grid(&mut self, layout: &GridLayout) {
532 // See `build_regions`: preserve `dropdown_anchor_y` across the
533 // per-frame rebuild so an idle frame that skips render doesn't
534 // strand the next dropdown popup at y = 0.
535 let prior_anchors: Vec<f32> = self
536 .knob_regions
537 .iter()
538 .map(|r| r.dropdown_anchor_y)
539 .collect();
540 self.knob_regions.clear();
541
542 let header_h = layout.header_height();
543 let section_offsets = compute_section_offsets(layout);
544
545 for gw in &layout.widgets {
546 let x = GRID_PADDING + gw.col as f32 * (layout.cell_size + GRID_GAP);
547 let y = header_h
548 + GRID_PADDING
549 + gw.row as f32 * (layout.cell_size + GRID_GAP)
550 + section_offsets[gw.row as usize];
551 let w = gw.col_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
552 let h = gw.row_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
553 let cx = x + w / 2.0;
554 let cy = y + h / 2.0 - 5.0;
555 let radius = w.min(h) / 2.0 - 4.0;
556
557 // Pre-populate widget_type from the explicit `widget` kind
558 // when the layout declares one. Callers that need
559 // range-based inference for `None` (BuiltinEditor) still
560 // overwrite this field after the call; for custom editors
561 // that always set `widget` via the `layout::dropdown` /
562 // `layout::knob` / … helpers, this means dispatch routes
563 // correctly out of the box.
564 let widget_type = widget_kind_to_type(gw.widget);
565
566 let idx = self.knob_regions.len();
567 self.knob_regions.push(WidgetRegion {
568 param_id: gw.param_id,
569 widget_type,
570 x,
571 y,
572 w,
573 h,
574 cx,
575 cy,
576 radius,
577 normalized_value: 0.0,
578 dropdown_anchor_y: prior_anchors.get(idx).copied().unwrap_or(0.0),
579 });
580 }
581 }
582}
583
584// ---------------------------------------------------------------------------
585// Public `dispatch` - drive widget interactions from input events.
586// ---------------------------------------------------------------------------
587
588/// Route a batch of input events through the widget tree, updating
589/// `state` in place (hover, drag origins, dropdown open/closed, …) and
590/// returning the sequence of parameter edits they imply.
591///
592/// `state.knob_regions` must be up to date for the current layout; callers
593/// typically call `state.build_regions_any(layout)` once after a layout
594/// change. `snapshot` provides read access to live parameter values.
595///
596/// This does NOT mutate any parameter store. Callers replay the returned
597/// `ParamEdit`s against their host interface.
598pub fn dispatch(
599 events: &[InputEvent],
600 layout: &Layout,
601 snapshot: &ParamSnapshot<'_>,
602 state: &mut InteractionState,
603) -> Vec<ParamEdit> {
604 let (w, h) = (layout.width(), layout.height());
605 dispatch_in(events, layout, (w, h), snapshot, state)
606}
607
608/// Like [`dispatch`] but takes explicit `window_size` in the same
609/// coordinate space as the layout - i.e. the size of the surface the
610/// layout is being composited onto.
611///
612/// Use this when the layout is a chrome panel overlaid on a larger
613/// custom-rendered surface (visualizers, graphs, canvases). It lets
614/// dropdown popups and other bounds-aware overlays use the full
615/// window rather than being clipped to the layout's bounding box -
616/// otherwise a popup that wouldn't fit below the button flips above
617/// it even when there's room below in the outer window.
618// Window dimensions widen `u32 as f32`; window sizes are bounded by
619// display dimensions, well below 2^23.
620#[allow(clippy::cast_precision_loss)]
621pub fn dispatch_in(
622 events: &[InputEvent],
623 layout: &Layout,
624 window_size: (u32, u32),
625 snapshot: &ParamSnapshot<'_>,
626 state: &mut InteractionState,
627) -> Vec<ParamEdit> {
628 let mut edits = Vec::new();
629 let window_w = window_size.0 as f32;
630 let window_h = window_size.1 as f32;
631
632 for ev in events {
633 match *ev {
634 InputEvent::MouseMove { pointer_id, x, y } => {
635 // Popup-drag wins over knob-drag - a finger that
636 // landed inside the open popup scrolls the list,
637 // not any widget under it.
638 if let Some(drag) = state.popup_drag.as_ref()
639 && drag.pointer_id == pointer_id
640 {
641 apply_popup_scroll_drag(y, state);
642 continue;
643 }
644 let drag_info = state
645 .drag_for(pointer_id)
646 .map(|d| (d.widget_type, d.region_idx));
647 if let Some((wtype, region_idx)) = drag_info {
648 let y_id = if wtype == WidgetType::XYPad {
649 layout_param_id_y(layout, region_idx)
650 } else {
651 None
652 };
653 apply_drag(pointer_id, x, y, y_id, state, &mut edits);
654 } else {
655 // Hover / dropdown-hover are single-cursor concepts;
656 // skip for genuine multi-touch pointers so a second
657 // finger landing doesn't yank hover state away from
658 // the cursor's last position on a hybrid Mac.
659 if pointer_id == SINGLE_POINTER {
660 if state.dropdown_is_open() {
661 state.dropdown_update_hover(x, y);
662 }
663 state.hover_idx = state.hit_test(x, y);
664 }
665 }
666 }
667 InputEvent::MouseDown {
668 pointer_id,
669 x,
670 y,
671 button: MouseButton::Left,
672 } => {
673 handle_mouse_down(
674 pointer_id, x, y, layout, snapshot, state, window_w, window_h, &mut edits,
675 );
676 }
677 InputEvent::MouseUp {
678 pointer_id,
679 x,
680 y,
681 button: MouseButton::Left,
682 } => {
683 // Popup-drag end: if the user didn't scroll
684 // appreciably (stayed within `ITEM_H / 2` of the
685 // start), treat the touch as a tap and commit the
686 // option under the release point. If they did
687 // scroll, just keep the popup open.
688 if let Some(drag) = state.popup_drag.take()
689 && drag.pointer_id == pointer_id
690 {
691 if !drag.scrolled
692 && let Some(option_idx) = state.dropdown_popup_hit(x, y)
693 && let Some(dd) = state.dropdown.as_ref()
694 {
695 let param_id = dd.param_id;
696 let count = dd.options.len();
697 let new_norm = f32::from_f64(discrete_norm(option_idx, count));
698 edits.push(ParamEdit::Begin { id: param_id });
699 edits.push(ParamEdit::Set {
700 id: param_id,
701 normalized: new_norm,
702 });
703 edits.push(ParamEdit::End { id: param_id });
704 state.dropdown_close();
705 }
706 continue;
707 }
708 if let Some(drag) = state.end_drag(pointer_id) {
709 edits.push(ParamEdit::End { id: drag.param_id });
710 if drag.widget_type == WidgetType::XYPad
711 && let Some(y_id) = layout_param_id_y(layout, drag.region_idx)
712 {
713 edits.push(ParamEdit::End { id: y_id });
714 }
715 }
716 }
717 InputEvent::MouseDoubleClick { x, y } => {
718 if let Some(idx) = state.hit_test(x, y) {
719 let param_id = state.knob_regions[idx].param_id;
720 let default_norm = (snapshot.default_normalized)(param_id);
721 edits.push(ParamEdit::Begin { id: param_id });
722 edits.push(ParamEdit::Set {
723 id: param_id,
724 normalized: default_norm,
725 });
726 edits.push(ParamEdit::End { id: param_id });
727 }
728 }
729 InputEvent::Scroll { x, y, dy } => {
730 if state.dropdown_is_open() {
731 // An open dropdown captures ALL scroll input: wheel
732 // inside the popup scrolls the list, wheel outside
733 // is absorbed (no-op) so it can't fall through to
734 // the generic knob-scroll path below and silently
735 // advance the param driving this very dropdown.
736 let inside_popup = state.dropdown_popup_hit(x, y).is_some()
737 || state.dropdown.as_ref().is_some_and(|dd| {
738 let (px, py, pw, ph) = dd.popup_rect;
739 x >= px && x <= px + pw && y >= py && y <= py + ph
740 });
741 if inside_popup {
742 // dy == 0 should be a no-op - falling through to
743 // the else branch would silently scroll +1 each
744 // time a host emits a zero-magnitude wheel event.
745 let delta = match dy.partial_cmp(&0.0) {
746 Some(std::cmp::Ordering::Greater) => -1,
747 Some(std::cmp::Ordering::Less) => 1,
748 _ => 0,
749 };
750 if delta != 0 {
751 state.dropdown_scroll(delta);
752 }
753 }
754 continue;
755 }
756 if let Some(idx) = state.hit_test(x, y) {
757 // Only scroll-adjust continuous-value widgets.
758 // Dropdowns / Selectors / Toggles are discrete UI
759 // affordances - the user expects click to cycle,
760 // not wheel to drag them across their whole range.
761 let wtype = state.knob_regions[idx].widget_type;
762 if matches!(
763 wtype,
764 WidgetType::Knob | WidgetType::Slider | WidgetType::XYPad,
765 ) {
766 let param_id = state.knob_regions[idx].param_id;
767 let norm = (snapshot.get_param)(param_id);
768 let step = dy / KNOB_PIXELS_PER_UNIT;
769 let new_norm = (norm + step).clamp(0.0, 1.0);
770 edits.push(ParamEdit::Begin { id: param_id });
771 edits.push(ParamEdit::Set {
772 id: param_id,
773 normalized: new_norm,
774 });
775 edits.push(ParamEdit::End { id: param_id });
776 }
777 }
778 }
779 InputEvent::MouseLeave => {
780 if state.hover_idx.is_some() {
781 state.hover_idx = None;
782 state.needs_repaint = true;
783 }
784 }
785 // Right- and middle-click are intentionally ignored. The
786 // built-in editor doesn't have a context menu of its own,
787 // and most plugin hosts (VST3, AU, AAX) treat right-click
788 // inside the editor surface as their hook for the host's
789 // own automation / parameter-link menu - swallowing the
790 // event here would suppress that.
791 InputEvent::MouseDown { .. } | InputEvent::MouseUp { .. } => {}
792 }
793 }
794
795 edits
796}
797
798/// Mouse-down handling factored out of the big match so it's readable.
799fn handle_mouse_down(
800 pointer_id: u64,
801 x: f32,
802 y: f32,
803 layout: &Layout,
804 snapshot: &ParamSnapshot<'_>,
805 state: &mut InteractionState,
806 window_w: f32,
807 window_h: f32,
808 edits: &mut Vec<ParamEdit>,
809) {
810 // If a dropdown popup is open, handle it first.
811 if let Some(dd) = state.dropdown.as_ref() {
812 // MouseDown inside the popup starts a touch-drag - the
813 // commit-or-scroll decision is deferred to MouseUp based
814 // on whether the user moved or stayed still. Without
815 // this, every tap on the popup commits immediately and
816 // there's no way for touch users to scroll a list longer
817 // than the visible area.
818 let (px, py, pw, ph) = dd.popup_rect;
819 if x >= px && x <= px + pw && y >= py && y <= py + ph {
820 state.popup_drag = Some(PopupDrag {
821 pointer_id,
822 start_y: y,
823 start_scroll_offset: dd.scroll_offset,
824 scrolled: false,
825 });
826 return;
827 }
828 // Click outside popup: close. If it landed on the same dropdown
829 // button, swallow the click (don't reopen).
830 if let Some(open_region) = state.dropdown_close()
831 && let Some(idx) = state.hit_test(x, y)
832 && idx == open_region
833 && state.widget_type_at(idx) == Some(WidgetType::Dropdown)
834 {
835 return;
836 }
837 // Fall through to normal widget hit-test.
838 }
839
840 let Some(idx) = state.hit_test(x, y) else {
841 return;
842 };
843 let param_id = state.knob_regions[idx].param_id;
844 let wtype = state.widget_type_at(idx);
845
846 match wtype {
847 Some(WidgetType::Toggle) => {
848 let norm = (snapshot.get_param)(param_id);
849 let new_norm = if norm > 0.5 { 0.0 } else { 1.0 };
850 edits.push(ParamEdit::Begin { id: param_id });
851 edits.push(ParamEdit::Set {
852 id: param_id,
853 normalized: new_norm,
854 });
855 edits.push(ParamEdit::End { id: param_id });
856 }
857 Some(WidgetType::Selector) => {
858 let new_norm = (snapshot.next_discrete_normalized)(param_id);
859 edits.push(ParamEdit::Begin { id: param_id });
860 edits.push(ParamEdit::Set {
861 id: param_id,
862 normalized: new_norm,
863 });
864 edits.push(ParamEdit::End { id: param_id });
865 }
866 Some(WidgetType::Dropdown) => {
867 open_dropdown(idx, param_id, snapshot, state, window_w, window_h);
868 }
869 _ => {
870 // Knob / Slider / XYPad / Meter: begin a drag.
871 let norm = f64::from((snapshot.get_param)(param_id));
872 // If a system gesture stole the previous touch for this
873 // pointer_id without firing `touchesCancelled:`, the
874 // displaced drag's `Begin` is still on the host's
875 // gesture stack - flush an `End` for it (XY pads need
876 // both axes) before opening the new gesture.
877 if let Some(stranded) = state.begin_drag(pointer_id, idx, norm, y) {
878 edits.push(ParamEdit::End {
879 id: stranded.param_id,
880 });
881 if stranded.widget_type == WidgetType::XYPad
882 && let Some(y_id) = layout_param_id_y(layout, stranded.region_idx)
883 {
884 edits.push(ParamEdit::End { id: y_id });
885 }
886 }
887 edits.push(ParamEdit::Begin { id: param_id });
888 if wtype == Some(WidgetType::XYPad)
889 && let Some(y_id) = layout_param_id_y(layout, idx)
890 {
891 edits.push(ParamEdit::Begin { id: y_id });
892 }
893 }
894 }
895}
896
897// Layout / hit-test math is f32 logical pixels bounded by window size;
898// `((avail_h - padding * 2.0) / item_h)` lands in `[0, options.len()]`.
899#[allow(
900 clippy::cast_possible_truncation,
901 clippy::cast_sign_loss,
902 clippy::cast_precision_loss
903)]
904fn open_dropdown(
905 region_idx: usize,
906 param_id: u32,
907 snapshot: &ParamSnapshot<'_>,
908 state: &mut InteractionState,
909 window_w: f32,
910 window_h: f32,
911) {
912 let options = (snapshot.get_options)(param_id);
913 if options.is_empty() {
914 return;
915 }
916 let count = options.len();
917 let current_norm = (snapshot.get_param)(param_id);
918 let selected = discrete_index(f64::from(current_norm), count);
919 let region = &state.knob_regions[region_idx];
920
921 let item_h = 18.0f32;
922 let padding = 4.0f32;
923
924 let anchor_below = region.dropdown_anchor_y; // bottom of button box
925 let popup_w = region.w.max(80.0);
926 let full_popup_h = options.len() as f32 * item_h + padding * 2.0;
927
928 // Always anchor the popup directly under the dropdown button.
929 // If the full list doesn't fit between `anchor_below` and the
930 // window's bottom, cap `visible_count` and scroll - DON'T
931 // shift the popup upward to make more items fit. Shifting up
932 // landed the popup near `y = 0` (literally the top of the
933 // editor) for any dropdown whose full option list was taller
934 // than the editor, far from the button the user just tapped.
935 // Scrolling is the lesser annoyance.
936 let popup_y = anchor_below.max(0.0);
937 let space_below = (window_h - popup_y).max(item_h + padding * 2.0);
938 let avail_h = full_popup_h.min(space_below);
939
940 let visible_count = ((avail_h - padding * 2.0) / item_h).floor().max(1.0) as usize;
941 let visible_count = visible_count.min(options.len());
942 let popup_h = visible_count as f32 * item_h + padding * 2.0;
943
944 let popup_x = region.x.clamp(0.0, (window_w - popup_w).max(0.0));
945 let scroll_offset = if selected >= visible_count {
946 selected - visible_count + 1
947 } else {
948 0
949 };
950
951 state.dropdown = Some(DropdownState {
952 region_idx,
953 param_id,
954 popup_rect: (popup_x, popup_y, popup_w, popup_h),
955 options,
956 selected,
957 hover_option: None,
958 scroll_offset,
959 visible_count,
960 });
961}
962
963/// Touch scroll-drag on the open dropdown popup. Maps vertical
964/// motion since the drag started into `scroll_offset` changes
965/// (one item per `item_h` of drag). If the user has moved more
966/// than half an item from the start, flips `scrolled = true` so
967/// the `MouseUp` handler treats the touch as a scroll instead of
968/// a commit-on-tap.
969//
970// Cast contract: `start_scroll_offset` is bounded by
971// `dd.options.len()` which (per the dropdown widget shape) caps
972// at a few hundred - well below `i32::MAX`. `items_scrolled` is
973// `(dy / item_h)` where `dy` is a finite single-frame motion;
974// the product never approaches i32 limits.
975#[allow(
976 clippy::cast_possible_truncation,
977 clippy::cast_sign_loss,
978 clippy::cast_possible_wrap
979)]
980fn apply_popup_scroll_drag(y: f32, state: &mut InteractionState) {
981 let item_h = 18.0f32;
982 let (start_y, start_scroll_offset) = match state.popup_drag.as_ref() {
983 Some(d) => (d.start_y, d.start_scroll_offset),
984 None => return,
985 };
986 let dy = start_y - y;
987 if dy.abs() > item_h / 2.0
988 && let Some(d) = state.popup_drag.as_mut()
989 {
990 d.scrolled = true;
991 }
992 let items_scrolled = (dy / item_h).round() as i32;
993 let new_offset = start_scroll_offset as i32 + items_scrolled;
994 let mut changed = false;
995 if let Some(dd) = state.dropdown.as_mut() {
996 let max_offset = (dd.options.len() as i32 - dd.visible_count as i32).max(0);
997 let clamped = new_offset.clamp(0, max_offset) as usize;
998 if clamped != dd.scroll_offset {
999 dd.scroll_offset = clamped;
1000 changed = true;
1001 }
1002 }
1003 if changed {
1004 state.needs_repaint = true;
1005 }
1006}
1007
1008fn apply_drag(
1009 pointer_id: u64,
1010 x: f32,
1011 y: f32,
1012 y_id_for_xy: Option<u32>,
1013 state: &InteractionState,
1014 edits: &mut Vec<ParamEdit>,
1015) {
1016 let Some(drag) = state.drag_for(pointer_id) else {
1017 return;
1018 };
1019 match drag.widget_type {
1020 WidgetType::XYPad => {
1021 let pad_margin = 4.0;
1022 let label_h = 18.0;
1023 let pad_x = drag.region_x + pad_margin;
1024 let pad_w = drag.region_w - pad_margin * 2.0;
1025 let pad_y_start = drag.region_y + pad_margin;
1026 let pad_h = drag.region_h - pad_margin * 2.0 - label_h;
1027
1028 let norm_x = ((x - pad_x) / pad_w).clamp(0.0, 1.0);
1029 let norm_y = (1.0 - (y - pad_y_start) / pad_h).clamp(0.0, 1.0);
1030
1031 edits.push(ParamEdit::Set {
1032 id: drag.param_id,
1033 normalized: norm_x,
1034 });
1035 if let Some(y_id) = y_id_for_xy {
1036 edits.push(ParamEdit::Set {
1037 id: y_id,
1038 normalized: norm_y,
1039 });
1040 }
1041 }
1042 WidgetType::Slider => {
1043 if let Some((pid, new_norm)) = state.update_slider_drag(pointer_id, x) {
1044 edits.push(ParamEdit::Set {
1045 id: pid,
1046 normalized: f32::from_f64(new_norm),
1047 });
1048 }
1049 }
1050 _ => {
1051 if let Some((pid, new_norm)) = state.update_drag(pointer_id, y) {
1052 edits.push(ParamEdit::Set {
1053 id: pid,
1054 normalized: f32::from_f64(new_norm),
1055 });
1056 }
1057 }
1058 }
1059}
1060
1061/// Look up the Y-axis parameter ID for a widget at `region_idx` in the layout.
1062/// Returns `None` if the widget is not an XY pad (or the index is invalid).
1063pub(crate) fn layout_param_id_y(layout: &Layout, region_idx: usize) -> Option<u32> {
1064 match layout {
1065 Layout::Rows(pl) => {
1066 let mut i = 0;
1067 for row in &pl.rows {
1068 for kd in &row.knobs {
1069 if i == region_idx {
1070 return kd.param_id_y;
1071 }
1072 i += 1;
1073 }
1074 }
1075 None
1076 }
1077 Layout::Grid(g) => g.widgets.get(region_idx).and_then(|w| w.param_id_y),
1078 }
1079}