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:
Mikkeli Matlock
2026-06-24 16:55:30 +09:00
parent 514fd964f6
commit 3aeb09957a
2 changed files with 144 additions and 6 deletions
+141
View File
@@ -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, &params.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, &params.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, &params.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, &params.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
View File
@@ -19,6 +19,7 @@ use crate::meters::Meters;
use crate::params::{Codename206Params, CompressorParams}; use crate::params::{Codename206Params, CompressorParams};
use crate::Codename206; use crate::Codename206;
mod crossover;
mod meter; mod meter;
mod plot; mod plot;
@@ -75,14 +76,10 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
ui.separator(); ui.separator();
plot::draw(ui, &meters, &mut state.plot); plot::draw(ui, &meters, &mut state.plot);
ui.separator(); ui.separator();
crossover::draw(ui, &params, setter);
ui.separator();
// Global controls stacked vertically so they never overflow sideways. // Global controls stacked vertically so they never overflow sideways.
egui::Grid::new("globals").num_columns(2).show(ui, |ui| { egui::Grid::new("globals").num_columns(2).show(ui, |ui| {
ui.label("Xover Lo/Mid");
ui.add(widgets::ParamSlider::for_param(&params.crossover_low_hz, setter));
ui.end_row();
ui.label("Xover Mid/Hi");
ui.add(widgets::ParamSlider::for_param(&params.crossover_high_hz, setter));
ui.end_row();
ui.label("Look-ahead"); ui.label("Look-ahead");
ui.add(widgets::ParamSlider::for_param(&params.look_ahead_ms, setter)); ui.add(widgets::ParamSlider::for_param(&params.look_ahead_ms, setter));
ui.end_row(); ui.end_row();