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}