diff --git a/src/editor/crossover.rs b/src/editor/crossover.rs new file mode 100644 index 0000000..97b39eb --- /dev/null +++ b/src/editor/crossover.rs @@ -0,0 +1,141 @@ +//! 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::Codename206Params; + +/// 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: &Codename206Params, 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, Color32::from_rgb(28, 38, 52)); + p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, Color32::from_rgb(30, 48, 36)); + p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, Color32::from_rgb(52, 38, 30)); + + let band_label = |cx: f32, t: &str| { + p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), Color32::from_gray(160)); + }; + 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, Color32::from_rgb(120, 170, 230)); + handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, Color32::from_rgb(230, 150, 120)); + + // 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() { Color32::WHITE } else { color }; + p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0, col)); + p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), Color32::from_gray(220)); +} + +/// 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); +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 30f51b5..22096e9 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -19,6 +19,7 @@ use crate::meters::Meters; use crate::params::{Codename206Params, CompressorParams}; use crate::Codename206; +mod crossover; mod meter; mod plot; @@ -75,14 +76,10 @@ pub(crate) fn create(params: Arc, meters: Arc) -> Opt ui.separator(); plot::draw(ui, &meters, &mut state.plot); ui.separator(); + crossover::draw(ui, ¶ms, setter); + ui.separator(); // Global controls stacked vertically so they never overflow sideways. egui::Grid::new("globals").num_columns(2).show(ui, |ui| { - ui.label("Xover Lo/Mid"); - ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter)); - ui.end_row(); - ui.label("Xover Mid/Hi"); - ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter)); - ui.end_row(); ui.label("Look-ahead"); ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter)); ui.end_row();