feat: draggable crossover handles with dynamic lo<=hi limit
Replace the two plain crossover sliders with a horizontal log-frequency strip (LOW/MID/HIGH) and two draggable handles, plus number boxes (double-click to type). Handles and boxes enforce a dynamic limit so lo/mid never crosses mid/hi, on top of each param's own range. Lives in the editor/ widget module. Known limitation: the lo<=hi limit is editor-only, so host automation can write the two params past each other (briefly inverting the mid band). The DSP clamps to monotonic, but FL's automation can misbehave once inverted. Left as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+3
-6
@@ -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<Codename206Params>, meters: Arc<Meters>) -> 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();
|
||||
|
||||
Reference in New Issue
Block a user