use truce_core::Float;
use truce_core::cast::{discrete_index, discrete_norm};
use crate::layout::{
GRID_GAP, GRID_PADDING, GridLayout, Layout, PluginLayout, ROWS_COLUMN_GAP, ROWS_LAYOUT_TOP,
ROWS_ROW_GAP, ROWS_SECTION_LABEL_HEIGHT, WidgetKind, compute_section_offsets,
};
use crate::snapshot::ParamSnapshot;
use crate::widgets::WidgetType;
#[allow(clippy::match_same_arms)]
fn widget_kind_to_type(kind: Option<WidgetKind>) -> WidgetType {
match kind {
Some(WidgetKind::Knob) => WidgetType::Knob,
Some(WidgetKind::Slider) => WidgetType::Slider,
Some(WidgetKind::Toggle) => WidgetType::Toggle,
Some(WidgetKind::Selector) => WidgetType::Selector,
Some(WidgetKind::Dropdown) => WidgetType::Dropdown,
Some(WidgetKind::Meter) => WidgetType::Meter,
Some(WidgetKind::XYPad) => WidgetType::XYPad,
None => WidgetType::Knob,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
Left,
Right,
Middle,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool,
}
#[derive(Clone, Copy, Debug)]
pub enum InputEvent {
MouseMove {
pointer_id: u64,
x: f32,
y: f32,
},
MouseDown {
pointer_id: u64,
x: f32,
y: f32,
button: MouseButton,
},
MouseUp {
pointer_id: u64,
x: f32,
y: f32,
button: MouseButton,
},
MouseDoubleClick {
x: f32,
y: f32,
},
Scroll {
x: f32,
y: f32,
dy: f32,
},
MouseLeave,
}
pub const SINGLE_POINTER: u64 = 0;
const KNOB_PIXELS_PER_UNIT: f32 = 200.0;
#[derive(Clone, Copy, Debug)]
pub enum ParamEdit {
Begin { id: u32 },
Set { id: u32, normalized: f32 },
End { id: u32 },
}
#[derive(Clone, Debug)]
pub struct WidgetRegion {
pub param_id: u32,
pub widget_type: WidgetType,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub cx: f32,
pub cy: f32,
pub radius: f32,
pub normalized_value: f32,
pub dropdown_anchor_y: f32,
}
pub struct DropdownState {
pub region_idx: usize,
pub param_id: u32,
pub popup_rect: (f32, f32, f32, f32),
pub options: Vec<String>,
pub selected: usize,
pub hover_option: Option<usize>,
pub scroll_offset: usize,
pub visible_count: usize,
}
#[derive(Default)]
pub struct InteractionState {
pub knob_regions: Vec<WidgetRegion>,
pub drags: Vec<DragState>,
pub hover_idx: Option<usize>,
pub dropdown: Option<DropdownState>,
pub popup_drag: Option<PopupDrag>,
needs_repaint: bool,
}
pub struct PopupDrag {
pub pointer_id: u64,
pub start_y: f32,
pub start_scroll_offset: usize,
pub scrolled: bool,
}
pub struct DragState {
pub pointer_id: u64,
pub region_idx: usize,
pub param_id: u32,
pub start_value: f64,
pub start_y: f32,
pub widget_type: WidgetType,
pub region_x: f32,
pub region_y: f32,
pub region_w: f32,
pub region_h: f32,
}
impl InteractionState {
pub fn take_repaint_request(&mut self) -> bool {
std::mem::replace(&mut self.needs_repaint, false)
}
#[allow(clippy::cast_precision_loss)]
pub fn build_regions(&mut self, layout: &PluginLayout) {
let prior_anchors: Vec<f32> = self
.knob_regions
.iter()
.map(|r| r.dropdown_anchor_y)
.collect();
self.knob_regions.clear();
let knob_size = layout.knob_size;
let pitch = knob_size + ROWS_COLUMN_GAP;
let mut y = ROWS_LAYOUT_TOP;
for row in &layout.rows {
if row.label.is_some() {
y += ROWS_SECTION_LABEL_HEIGHT;
}
let total_cols: u32 = row.knobs.iter().map(|k| k.span.max(1)).sum();
let total_w = total_cols as f32 * pitch - ROWS_COLUMN_GAP;
let start_x = (layout.width as f32 - total_w) / 2.0;
let mut col = 0u32;
for knob_def in &row.knobs {
let span = knob_def.span.max(1);
let x = start_x + col as f32 * pitch;
let widget_w = span as f32 * pitch - ROWS_COLUMN_GAP;
let cx = x + widget_w / 2.0;
let cy = y + knob_size / 2.0 - 5.0;
let radius = knob_size / 2.0 - 4.0;
let idx = self.knob_regions.len();
self.knob_regions.push(WidgetRegion {
param_id: knob_def.param_id,
widget_type: widget_kind_to_type(knob_def.widget),
x,
y,
w: widget_w,
h: knob_size,
cx,
cy,
radius,
normalized_value: 0.0,
dropdown_anchor_y: prior_anchors.get(idx).copied().unwrap_or(0.0),
});
col += span;
}
y += knob_size + ROWS_ROW_GAP;
}
}
#[must_use]
pub fn hit_test(&self, mx: f32, my: f32) -> Option<usize> {
for (idx, region) in self.knob_regions.iter().enumerate() {
match region.widget_type {
WidgetType::Knob => {
let dx = mx - region.cx;
let dy = my - region.cy;
if dx * dx + dy * dy <= region.radius * region.radius {
return Some(idx);
}
}
WidgetType::Meter => {}
WidgetType::Slider
| WidgetType::Toggle
| WidgetType::Selector
| WidgetType::Dropdown
| WidgetType::XYPad => {
if mx >= region.x
&& mx <= region.x + region.w
&& my >= region.y
&& my <= region.y + region.h
{
return Some(idx);
}
}
}
}
None
}
#[must_use]
pub fn widget_type_at(&self, idx: usize) -> Option<WidgetType> {
self.knob_regions.get(idx).map(|r| r.widget_type)
}
#[must_use]
pub fn region_at(&self, idx: usize) -> Option<&WidgetRegion> {
self.knob_regions.get(idx)
}
#[must_use]
pub fn begin_drag(
&mut self,
pointer_id: u64,
idx: usize,
current_normalized: f64,
mouse_y: f32,
) -> Option<DragState> {
let region = self.knob_regions.get(idx)?;
let param_id = region.param_id;
let wtype = region.widget_type;
let stranded = self
.drags
.iter()
.position(|d| d.pointer_id == pointer_id)
.map(|i| self.drags.swap_remove(i));
self.drags.push(DragState {
pointer_id,
region_idx: idx,
param_id,
start_value: current_normalized,
start_y: mouse_y,
widget_type: wtype,
region_x: region.x,
region_y: region.y,
region_w: region.w,
region_h: region.h,
});
stranded
}
#[must_use]
pub fn drag_for(&self, pointer_id: u64) -> Option<&DragState> {
self.drags.iter().find(|d| d.pointer_id == pointer_id)
}
#[must_use]
pub fn update_drag(&self, pointer_id: u64, mouse_y: f32) -> Option<(u32, f64)> {
let drag = self.drag_for(pointer_id)?;
let dy = drag.start_y - mouse_y;
let delta = f64::from(dy) / f64::from(KNOB_PIXELS_PER_UNIT);
let new_value = (drag.start_value + delta).clamp(0.0, 1.0);
Some((drag.param_id, new_value))
}
#[must_use]
pub fn update_slider_drag(&self, pointer_id: u64, mouse_x: f32) -> Option<(u32, f64)> {
let drag = self.drag_for(pointer_id)?;
let margin = 4.0;
let rel = (mouse_x - drag.region_x - margin) / (drag.region_w - margin * 2.0);
let new_value = f64::from(rel).clamp(0.0, 1.0);
Some((drag.param_id, new_value))
}
pub fn end_drag(&mut self, pointer_id: u64) -> Option<DragState> {
let idx = self.drags.iter().position(|d| d.pointer_id == pointer_id)?;
Some(self.drags.swap_remove(idx))
}
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn dropdown_popup_hit(&self, mx: f32, my: f32) -> Option<usize> {
let dd = self.dropdown.as_ref()?;
let (px, py, pw, ph) = dd.popup_rect;
if mx < px || mx > px + pw || my < py || my > py + ph {
return None;
}
let item_h = 18.0f32;
let padding = 4.0f32;
let local_idx = ((my - py - padding) / item_h) as usize;
let abs_idx = dd.scroll_offset + local_idx;
if abs_idx < dd.options.len() && local_idx < dd.visible_count {
Some(abs_idx)
} else {
None
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn dropdown_update_hover(&mut self, mx: f32, my: f32) {
if let Some(ref mut dd) = self.dropdown {
let (px, py, pw, ph) = dd.popup_rect;
if mx >= px && mx <= px + pw && my >= py && my <= py + ph {
let item_h = 18.0f32;
let padding = 4.0f32;
let local_idx = ((my - py - padding) / item_h) as usize;
let abs_idx = dd.scroll_offset + local_idx;
dd.hover_option = if abs_idx < dd.options.len() && local_idx < dd.visible_count {
Some(abs_idx)
} else {
None
};
} else {
dd.hover_option = None;
}
}
}
#[must_use]
pub fn dropdown_is_open(&self) -> bool {
self.dropdown.is_some()
}
pub fn dropdown_close(&mut self) -> Option<usize> {
self.dropdown.take().map(|dd| dd.region_idx)
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
pub fn dropdown_scroll(&mut self, delta: i32) {
if let Some(ref mut dd) = self.dropdown {
let max_offset = dd.options.len().saturating_sub(dd.visible_count);
let new_offset = (dd.scroll_offset as i32 + delta).clamp(0, max_offset as i32) as usize;
dd.scroll_offset = new_offset;
}
}
pub fn build_regions_any(&mut self, layout: &Layout) {
match layout {
Layout::Rows(pl) => self.build_regions(pl),
Layout::Grid(gl) => self.build_regions_grid(gl),
}
}
#[allow(clippy::cast_precision_loss)]
pub fn build_regions_grid(&mut self, layout: &GridLayout) {
let prior_anchors: Vec<f32> = self
.knob_regions
.iter()
.map(|r| r.dropdown_anchor_y)
.collect();
self.knob_regions.clear();
let header_h = layout.header_height();
let section_offsets = compute_section_offsets(layout);
for gw in &layout.widgets {
let x = GRID_PADDING + gw.col as f32 * (layout.cell_size + GRID_GAP);
let y = header_h
+ GRID_PADDING
+ gw.row as f32 * (layout.cell_size + GRID_GAP)
+ section_offsets[gw.row as usize];
let w = gw.col_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
let h = gw.row_span as f32 * (layout.cell_size + GRID_GAP) - GRID_GAP;
let cx = x + w / 2.0;
let cy = y + h / 2.0 - 5.0;
let radius = w.min(h) / 2.0 - 4.0;
let widget_type = widget_kind_to_type(gw.widget);
let idx = self.knob_regions.len();
self.knob_regions.push(WidgetRegion {
param_id: gw.param_id,
widget_type,
x,
y,
w,
h,
cx,
cy,
radius,
normalized_value: 0.0,
dropdown_anchor_y: prior_anchors.get(idx).copied().unwrap_or(0.0),
});
}
}
}
pub fn dispatch(
events: &[InputEvent],
layout: &Layout,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
) -> Vec<ParamEdit> {
let (w, h) = (layout.width(), layout.height());
dispatch_in(events, layout, (w, h), snapshot, state)
}
#[allow(clippy::cast_precision_loss)]
pub fn dispatch_in(
events: &[InputEvent],
layout: &Layout,
window_size: (u32, u32),
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
) -> Vec<ParamEdit> {
let mut edits = Vec::new();
let window_w = window_size.0 as f32;
let window_h = window_size.1 as f32;
for ev in events {
match *ev {
InputEvent::MouseMove { pointer_id, x, y } => {
if let Some(drag) = state.popup_drag.as_ref()
&& drag.pointer_id == pointer_id
{
apply_popup_scroll_drag(y, state);
continue;
}
let drag_info = state
.drag_for(pointer_id)
.map(|d| (d.widget_type, d.region_idx));
if let Some((wtype, region_idx)) = drag_info {
let y_id = if wtype == WidgetType::XYPad {
layout_param_id_y(layout, region_idx)
} else {
None
};
apply_drag(pointer_id, x, y, y_id, state, &mut edits);
} else {
if pointer_id == SINGLE_POINTER {
if state.dropdown_is_open() {
state.dropdown_update_hover(x, y);
}
state.hover_idx = state.hit_test(x, y);
}
}
}
InputEvent::MouseDown {
pointer_id,
x,
y,
button: MouseButton::Left,
} => {
handle_mouse_down(
pointer_id, x, y, layout, snapshot, state, window_w, window_h, &mut edits,
);
}
InputEvent::MouseUp {
pointer_id,
x,
y,
button: MouseButton::Left,
} => {
if let Some(drag) = state.popup_drag.take()
&& drag.pointer_id == pointer_id
{
if !drag.scrolled
&& let Some(option_idx) = state.dropdown_popup_hit(x, y)
&& let Some(dd) = state.dropdown.as_ref()
{
let param_id = dd.param_id;
let count = dd.options.len();
let new_norm = f32::from_f64(discrete_norm(option_idx, count));
edits.push(ParamEdit::Begin { id: param_id });
edits.push(ParamEdit::Set {
id: param_id,
normalized: new_norm,
});
edits.push(ParamEdit::End { id: param_id });
state.dropdown_close();
}
continue;
}
if let Some(drag) = state.end_drag(pointer_id) {
edits.push(ParamEdit::End { id: drag.param_id });
if drag.widget_type == WidgetType::XYPad
&& let Some(y_id) = layout_param_id_y(layout, drag.region_idx)
{
edits.push(ParamEdit::End { id: y_id });
}
}
}
InputEvent::MouseDoubleClick { x, y } => {
if let Some(idx) = state.hit_test(x, y) {
let param_id = state.knob_regions[idx].param_id;
let default_norm = (snapshot.default_normalized)(param_id);
edits.push(ParamEdit::Begin { id: param_id });
edits.push(ParamEdit::Set {
id: param_id,
normalized: default_norm,
});
edits.push(ParamEdit::End { id: param_id });
}
}
InputEvent::Scroll { x, y, dy } => {
if state.dropdown_is_open() {
let inside_popup = state.dropdown_popup_hit(x, y).is_some()
|| state.dropdown.as_ref().is_some_and(|dd| {
let (px, py, pw, ph) = dd.popup_rect;
x >= px && x <= px + pw && y >= py && y <= py + ph
});
if inside_popup {
let delta = match dy.partial_cmp(&0.0) {
Some(std::cmp::Ordering::Greater) => -1,
Some(std::cmp::Ordering::Less) => 1,
_ => 0,
};
if delta != 0 {
state.dropdown_scroll(delta);
}
}
continue;
}
if let Some(idx) = state.hit_test(x, y) {
let wtype = state.knob_regions[idx].widget_type;
if matches!(
wtype,
WidgetType::Knob | WidgetType::Slider | WidgetType::XYPad,
) {
let param_id = state.knob_regions[idx].param_id;
let norm = (snapshot.get_param)(param_id);
let step = dy / KNOB_PIXELS_PER_UNIT;
let new_norm = (norm + step).clamp(0.0, 1.0);
edits.push(ParamEdit::Begin { id: param_id });
edits.push(ParamEdit::Set {
id: param_id,
normalized: new_norm,
});
edits.push(ParamEdit::End { id: param_id });
}
}
}
InputEvent::MouseLeave => {
if state.hover_idx.is_some() {
state.hover_idx = None;
state.needs_repaint = true;
}
}
InputEvent::MouseDown { .. } | InputEvent::MouseUp { .. } => {}
}
}
edits
}
fn handle_mouse_down(
pointer_id: u64,
x: f32,
y: f32,
layout: &Layout,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
window_w: f32,
window_h: f32,
edits: &mut Vec<ParamEdit>,
) {
if let Some(dd) = state.dropdown.as_ref() {
let (px, py, pw, ph) = dd.popup_rect;
if x >= px && x <= px + pw && y >= py && y <= py + ph {
state.popup_drag = Some(PopupDrag {
pointer_id,
start_y: y,
start_scroll_offset: dd.scroll_offset,
scrolled: false,
});
return;
}
if let Some(open_region) = state.dropdown_close()
&& let Some(idx) = state.hit_test(x, y)
&& idx == open_region
&& state.widget_type_at(idx) == Some(WidgetType::Dropdown)
{
return;
}
}
let Some(idx) = state.hit_test(x, y) else {
return;
};
let param_id = state.knob_regions[idx].param_id;
let wtype = state.widget_type_at(idx);
match wtype {
Some(WidgetType::Toggle) => {
let norm = (snapshot.get_param)(param_id);
let new_norm = if norm > 0.5 { 0.0 } else { 1.0 };
edits.push(ParamEdit::Begin { id: param_id });
edits.push(ParamEdit::Set {
id: param_id,
normalized: new_norm,
});
edits.push(ParamEdit::End { id: param_id });
}
Some(WidgetType::Selector) => {
let new_norm = (snapshot.next_discrete_normalized)(param_id);
edits.push(ParamEdit::Begin { id: param_id });
edits.push(ParamEdit::Set {
id: param_id,
normalized: new_norm,
});
edits.push(ParamEdit::End { id: param_id });
}
Some(WidgetType::Dropdown) => {
open_dropdown(idx, param_id, snapshot, state, window_w, window_h);
}
_ => {
let norm = f64::from((snapshot.get_param)(param_id));
if let Some(stranded) = state.begin_drag(pointer_id, idx, norm, y) {
edits.push(ParamEdit::End {
id: stranded.param_id,
});
if stranded.widget_type == WidgetType::XYPad
&& let Some(y_id) = layout_param_id_y(layout, stranded.region_idx)
{
edits.push(ParamEdit::End { id: y_id });
}
}
edits.push(ParamEdit::Begin { id: param_id });
if wtype == Some(WidgetType::XYPad)
&& let Some(y_id) = layout_param_id_y(layout, idx)
{
edits.push(ParamEdit::Begin { id: y_id });
}
}
}
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn open_dropdown(
region_idx: usize,
param_id: u32,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
window_w: f32,
window_h: f32,
) {
let options = (snapshot.get_options)(param_id);
if options.is_empty() {
return;
}
let count = options.len();
let current_norm = (snapshot.get_param)(param_id);
let selected = discrete_index(f64::from(current_norm), count);
let region = &state.knob_regions[region_idx];
let item_h = 18.0f32;
let padding = 4.0f32;
let anchor_below = region.dropdown_anchor_y; let popup_w = region.w.max(80.0);
let full_popup_h = options.len() as f32 * item_h + padding * 2.0;
let popup_y = anchor_below.max(0.0);
let space_below = (window_h - popup_y).max(item_h + padding * 2.0);
let avail_h = full_popup_h.min(space_below);
let visible_count = ((avail_h - padding * 2.0) / item_h).floor().max(1.0) as usize;
let visible_count = visible_count.min(options.len());
let popup_h = visible_count as f32 * item_h + padding * 2.0;
let popup_x = region.x.clamp(0.0, (window_w - popup_w).max(0.0));
let scroll_offset = if selected >= visible_count {
selected - visible_count + 1
} else {
0
};
state.dropdown = Some(DropdownState {
region_idx,
param_id,
popup_rect: (popup_x, popup_y, popup_w, popup_h),
options,
selected,
hover_option: None,
scroll_offset,
visible_count,
});
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
fn apply_popup_scroll_drag(y: f32, state: &mut InteractionState) {
let item_h = 18.0f32;
let (start_y, start_scroll_offset) = match state.popup_drag.as_ref() {
Some(d) => (d.start_y, d.start_scroll_offset),
None => return,
};
let dy = start_y - y;
if dy.abs() > item_h / 2.0
&& let Some(d) = state.popup_drag.as_mut()
{
d.scrolled = true;
}
let items_scrolled = (dy / item_h).round() as i32;
let new_offset = start_scroll_offset as i32 + items_scrolled;
if let Some(dd) = state.dropdown.as_mut() {
let max_offset = (dd.options.len() as i32 - dd.visible_count as i32).max(0);
dd.scroll_offset = new_offset.clamp(0, max_offset) as usize;
}
}
fn apply_drag(
pointer_id: u64,
x: f32,
y: f32,
y_id_for_xy: Option<u32>,
state: &InteractionState,
edits: &mut Vec<ParamEdit>,
) {
let Some(drag) = state.drag_for(pointer_id) else {
return;
};
match drag.widget_type {
WidgetType::XYPad => {
let pad_margin = 4.0;
let label_h = 18.0;
let pad_x = drag.region_x + pad_margin;
let pad_w = drag.region_w - pad_margin * 2.0;
let pad_y_start = drag.region_y + pad_margin;
let pad_h = drag.region_h - pad_margin * 2.0 - label_h;
let norm_x = ((x - pad_x) / pad_w).clamp(0.0, 1.0);
let norm_y = (1.0 - (y - pad_y_start) / pad_h).clamp(0.0, 1.0);
edits.push(ParamEdit::Set {
id: drag.param_id,
normalized: norm_x,
});
if let Some(y_id) = y_id_for_xy {
edits.push(ParamEdit::Set {
id: y_id,
normalized: norm_y,
});
}
}
WidgetType::Slider => {
if let Some((pid, new_norm)) = state.update_slider_drag(pointer_id, x) {
edits.push(ParamEdit::Set {
id: pid,
normalized: f32::from_f64(new_norm),
});
}
}
_ => {
if let Some((pid, new_norm)) = state.update_drag(pointer_id, y) {
edits.push(ParamEdit::Set {
id: pid,
normalized: f32::from_f64(new_norm),
});
}
}
}
}
pub(crate) fn layout_param_id_y(layout: &Layout, region_idx: usize) -> Option<u32> {
match layout {
Layout::Rows(pl) => {
let mut i = 0;
for row in &pl.rows {
for kd in &row.knobs {
if i == region_idx {
return kd.param_id_y;
}
i += 1;
}
}
None
}
Layout::Grid(g) => g.widgets.get(region_idx).and_then(|w| w.param_id_y),
}
}