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::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, ¶ms, 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(¶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.label("Look-ahead");
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|||||||
Reference in New Issue
Block a user