// SPDX-License-Identifier: GPL-3.0-or-later //! Draggable crossover handles — replaces the two plain crossover sliders. //! //! A horizontal log-frequency strip split into LOW / MID / HIGH by two draggable handles, plus a //! number box per crossover (double-click to type an exact value). The handles enforce a dynamic //! limit: lo/mid can never exceed mid/hi (and vice-versa), on top of each param's own range. use nih_plug::prelude::*; use nih_plug_egui::egui::{ self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke, }; use crate::params::Bari206Params; use super::palette; /// Height of the handle strip. const GRAPH_H: f32 = 54.0; /// Displayed frequency axis (log), independent of the params' own ranges. const DISP_MIN_HZ: f32 = 20.0; const DISP_MAX_HZ: f32 = 20_000.0; fn log_span() -> f32 { DISP_MAX_HZ.ln() - DISP_MIN_HZ.ln() } fn fmt_hz(hz: f32) -> String { if hz >= 1000.0 { format!("{:.2} kHz", hz / 1000.0) } else { format!("{:.0} Hz", hz) } } pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, setter: &ParamSetter) { let lo = params.crossover_low_hz.value(); let hi = params.crossover_high_hz.value(); // Each param's own min/max (normalized 0/1 map to the range ends). let lo_min = params.crossover_low_hz.preview_plain(0.0); let lo_max = params.crossover_low_hz.preview_plain(1.0); let hi_min = params.crossover_high_hz.preview_plain(0.0); let hi_max = params.crossover_high_hz.preview_plain(1.0); // Dynamic limits so the two never cross: lo <= hi. let lo_upper = lo_max.min(hi); let hi_lower = hi_min.max(lo); ui.label("Crossover"); let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), GRAPH_H), Sense::hover()); let p = ui.painter_at(rect); let (left, right, top, bottom) = (rect.left() + 2.0, rect.right() - 2.0, rect.top() + 2.0, rect.bottom() - 2.0); let width = right - left; let x_for = |hz: f32| left + (hz.max(1.0).ln() - DISP_MIN_HZ.ln()) / log_span() * width; let xlo = x_for(lo); let xhi = x_for(hi); // Three band regions. p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, palette::CHANNELS[0].strip); p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, palette::CHANNELS[1].strip); p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, palette::CHANNELS[2].strip); let band_label = |cx: f32, t: &str| { p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), palette::TEXT); }; band_label((left + xlo) * 0.5, "LOW"); band_label((xlo + xhi) * 0.5, "MID"); band_label((xhi + right) * 0.5, "HIGH"); handle(ui, &p, setter, "xover_lo", xlo, lo, ¶ms.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, palette::CHANNELS[0].handle); handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, palette::CHANNELS[2].handle); // Number boxes (double-click to type). Clamped to the same dynamic limits. ui.horizontal(|ui| { ui.label("Lo/Mid"); let mut v = lo as f64; if ui .add(DragValue::new(&mut v).range(lo_min as f64..=lo_upper as f64).speed(0.5).suffix(" Hz")) .changed() { set_clamped(setter, ¶ms.crossover_low_hz, v as f32, lo_min, lo_upper); } ui.add_space(16.0); ui.label("Mid/Hi"); let mut v = hi as f64; if ui .add(DragValue::new(&mut v).range(hi_lower as f64..=hi_max as f64).speed(2.0).suffix(" Hz")) .changed() { set_clamped(setter, ¶ms.crossover_high_hz, v as f32, hi_lower, hi_max); } }); } /// One draggable vertical handle. Drives `param` from the pointer's x (log-frequency), clamped to /// `[min, max]` (which already encodes the dynamic lo<=hi limit), with proper begin/end gestures. #[allow(clippy::too_many_arguments)] fn handle( ui: &egui::Ui, p: &Painter, setter: &ParamSetter, id_salt: &str, x: f32, hz: f32, param: &FloatParam, min: f32, max: f32, left: f32, width: f32, top: f32, bottom: f32, color: Color32, ) { let hit = Rect::from_min_max(pos2(x - 5.0, top), pos2(x + 5.0, bottom)); let resp = ui .interact(hit, ui.id().with(id_salt), Sense::drag()) .on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if resp.drag_started() { setter.begin_set_parameter(param); } if resp.dragged() { if let Some(pos) = resp.interact_pointer_pos() { let frac = ((pos.x - left) / width).clamp(0.0, 1.0); let new = (DISP_MIN_HZ.ln() + frac * log_span()).exp().clamp(min, max); setter.set_parameter(param, new); } } if resp.drag_stopped() { setter.end_set_parameter(param); } let col = if resp.dragged() || resp.hovered() { palette::TEXT_HOVER } else { color }; p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0_f32, col)); p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), palette::TEXT_BRIGHT); } /// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box). fn set_clamped(setter: &ParamSetter, param: &FloatParam, value: f32, min: f32, max: f32) { setter.begin_set_parameter(param); setter.set_parameter(param, value.clamp(min, max)); setter.end_set_parameter(param); }