Tester feedback asked for a threshold and a ratio. Each band now cuts like a small compressor: Threshold (dB above the local floor) replaces Selectivity, Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut out. Mix and Output move to the slider panel so the detection panel fits. common-ui gains rotary_text for knobs with custom value text.
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
|
||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||
|
||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||
pub const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||
pub const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||
pub const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||
pub const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||
pub const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||
pub const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||
pub const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||
pub const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||
/// Signal colour: cuts, active states, value arcs.
|
||
pub const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||
/// Measured input and neutral data.
|
||
pub const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||
pub const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||
pub fn style(ctx: &egui::Context) {
|
||
let mut fonts = egui::FontDefinitions::default();
|
||
fonts.font_data.insert(
|
||
"regular".into(),
|
||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||
);
|
||
fonts.font_data.insert(
|
||
"medium".into(),
|
||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||
);
|
||
fonts
|
||
.families
|
||
.get_mut(&egui::FontFamily::Proportional)
|
||
.unwrap()
|
||
.insert(0, "regular".into());
|
||
fonts.families.insert(
|
||
egui::FontFamily::Name("medium".into()),
|
||
vec![
|
||
"medium".into(),
|
||
"Ubuntu-Light".into(),
|
||
"NotoEmoji-Regular".into(),
|
||
"emoji-icon-font".into(),
|
||
],
|
||
);
|
||
ctx.set_fonts(fonts);
|
||
let mut s = (*ctx.style()).clone();
|
||
let v = &mut s.visuals;
|
||
*v = egui::Visuals::dark();
|
||
v.panel_fill = BG;
|
||
v.window_fill = PANEL;
|
||
v.extreme_bg_color = SCREEN;
|
||
v.faint_bg_color = PANEL;
|
||
v.override_text_color = Some(TEXT);
|
||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||
v.hyperlink_color = ACCENT;
|
||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||
for (w, fill, stroke) in [
|
||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||
(
|
||
&mut v.widgets.hovered,
|
||
Color32::from_rgb(45, 48, 54),
|
||
Color32::from_rgb(78, 82, 90),
|
||
),
|
||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||
] {
|
||
w.bg_fill = fill;
|
||
w.weak_bg_fill = fill;
|
||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||
w.corner_radius = 3.into();
|
||
}
|
||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||
v.slider_trailing_fill = true;
|
||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||
s.text_styles
|
||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||
s.text_styles
|
||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||
ctx.set_style(s);
|
||
}
|
||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||
pub fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||
let mut mesh = egui::Mesh::default();
|
||
let mix = |t: f32| {
|
||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||
Color32::from_rgb(
|
||
l(top.r(), bottom.r()),
|
||
l(top.g(), bottom.g()),
|
||
l(top.b(), bottom.b()),
|
||
)
|
||
};
|
||
mesh.colored_vertex(center, mix(0.5));
|
||
let n = 48;
|
||
for i in 0..=n {
|
||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||
let point = center + Vec2::angled(a) * radius;
|
||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||
if i > 0 {
|
||
mesh.add_triangle(0, i, i + 1);
|
||
}
|
||
}
|
||
p.add(egui::Shape::mesh(mesh));
|
||
}
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn rotary(
|
||
ui: &mut egui::Ui,
|
||
name: &str,
|
||
value: &mut f32,
|
||
range: std::ops::RangeInclusive<f32>,
|
||
default: f32,
|
||
unit: &str,
|
||
size: f32,
|
||
color: Color32,
|
||
) -> egui::Response {
|
||
let response = dial(ui, value, range.clone(), default, size, color);
|
||
dial_caption(ui, name, size);
|
||
ui.add(
|
||
egui::DragValue::new(value)
|
||
.range(range)
|
||
.speed(0.1)
|
||
.suffix(unit),
|
||
);
|
||
response
|
||
}
|
||
/// A rotary whose value field shows custom text, for values that are not a plain number
|
||
/// with a unit (a ratio, a mapped knob position).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn rotary_text(
|
||
ui: &mut egui::Ui,
|
||
name: &str,
|
||
value: &mut f32,
|
||
range: std::ops::RangeInclusive<f32>,
|
||
default: f32,
|
||
size: f32,
|
||
color: Color32,
|
||
format: impl Fn(f32) -> String,
|
||
parse: impl Fn(&str) -> Option<f32>,
|
||
) -> egui::Response {
|
||
let response = dial(ui, value, range.clone(), default, size, color);
|
||
dial_caption(ui, name, size);
|
||
let speed = (*range.end() - *range.start()) as f64 / 200.;
|
||
ui.add(
|
||
egui::DragValue::new(value)
|
||
.range(range)
|
||
.speed(speed)
|
||
.custom_formatter(|v, _| format(v as f32))
|
||
.custom_parser(|s| parse(s).map(f64::from)),
|
||
);
|
||
response
|
||
}
|
||
fn dial_caption(ui: &mut egui::Ui, name: &str, size: f32) {
|
||
ui.label(
|
||
RichText::new(name.to_uppercase())
|
||
.size(if size > 100. { 14. } else { 12. })
|
||
.color(MUTED),
|
||
);
|
||
}
|
||
/// The knob itself: drag, keys, double-click reset, and drawing.
|
||
fn dial(
|
||
ui: &mut egui::Ui,
|
||
value: &mut f32,
|
||
range: std::ops::RangeInclusive<f32>,
|
||
default: f32,
|
||
size: f32,
|
||
color: Color32,
|
||
) -> egui::Response {
|
||
let low = *range.start();
|
||
let high = *range.end();
|
||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||
if response.drag_started() {
|
||
response.request_focus();
|
||
}
|
||
if response.dragged() {
|
||
let delta = ui.input(|i| i.pointer.delta().y);
|
||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||
0.1
|
||
} else {
|
||
1.
|
||
};
|
||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||
}
|
||
if response.double_clicked() {
|
||
*value = default;
|
||
}
|
||
if response.has_focus() {
|
||
let step = if ui.input(|i| i.modifiers.shift) {
|
||
0.1
|
||
} else {
|
||
1.
|
||
};
|
||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||
*value = (*value + step).min(high);
|
||
}
|
||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||
{
|
||
*value = (*value - step).max(low);
|
||
}
|
||
}
|
||
let center = rect.center();
|
||
let radius = size * 0.46;
|
||
let p = ui.painter();
|
||
let start = std::f32::consts::PI * 0.75;
|
||
let span = std::f32::consts::PI * 1.5;
|
||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||
// Scale track and value arc around the knob.
|
||
for (fraction, stroke) in [
|
||
(1., Stroke::new(2.5_f32, DIM)),
|
||
(normalized, Stroke::new(2.5_f32, color)),
|
||
] {
|
||
// A zero-length polyline tessellates to NaN vertices.
|
||
if fraction < 1e-3 {
|
||
continue;
|
||
}
|
||
let points: Vec<_> = (0..=64)
|
||
.map(|i| {
|
||
let a = start + span * fraction * i as f32 / 64.;
|
||
center + Vec2::angled(a) * (radius - 1.5)
|
||
})
|
||
.collect();
|
||
p.add(egui::Shape::line(points, stroke));
|
||
}
|
||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||
let body = radius - 7.;
|
||
p.circle_filled(
|
||
center + Vec2::new(0., 2.),
|
||
body + 1.,
|
||
Color32::from_black_alpha(140),
|
||
);
|
||
metal_disc(
|
||
p,
|
||
center,
|
||
body,
|
||
Color32::from_rgb(78, 82, 90),
|
||
Color32::from_rgb(28, 30, 34),
|
||
);
|
||
p.circle_stroke(
|
||
center,
|
||
body,
|
||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||
);
|
||
let cap = body * 0.74;
|
||
metal_disc(
|
||
p,
|
||
center,
|
||
cap,
|
||
Color32::from_rgb(44, 47, 52),
|
||
Color32::from_rgb(33, 35, 40),
|
||
);
|
||
p.circle_stroke(
|
||
center,
|
||
cap,
|
||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||
);
|
||
let angle = start + span * normalized;
|
||
p.line_segment(
|
||
[
|
||
center + Vec2::angled(angle) * (cap * 0.35),
|
||
center + Vec2::angled(angle) * (cap - 2.),
|
||
],
|
||
Stroke::new(2.0_f32, TEXT),
|
||
);
|
||
if response.has_focus() {
|
||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||
}
|
||
response
|
||
}
|
||
/// Vertical gradient rectangle, used for panel faces.
|
||
pub fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||
let mut mesh = egui::Mesh::default();
|
||
for (pos, color) in [
|
||
(rect.left_top(), top),
|
||
(rect.right_top(), top),
|
||
(rect.right_bottom(), bottom),
|
||
(rect.left_bottom(), bottom),
|
||
] {
|
||
mesh.colored_vertex(pos, color);
|
||
}
|
||
mesh.add_triangle(0, 1, 2);
|
||
mesh.add_triangle(0, 2, 3);
|
||
egui::Shape::mesh(mesh)
|
||
}
|
||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||
pub fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||
let face = ui.painter().add(egui::Shape::Noop);
|
||
let inner = egui::Frame::new()
|
||
.inner_margin(14.)
|
||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||
.show(ui, add);
|
||
let r = inner.response.rect;
|
||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||
ui.painter().line_segment(
|
||
[
|
||
r.left_top() + Vec2::new(1., 1.),
|
||
r.right_top() + Vec2::new(-1., 1.),
|
||
],
|
||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||
);
|
||
inner.inner
|
||
}
|
||
pub fn caption(ui: &mut egui::Ui, text: &str) {
|
||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||
}
|
||
/// Small labelled hardware-style readout.
|
||
pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||
ui.add_space(10.);
|
||
}
|
||
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
|
||
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
|
||
ui.horizontal(|ui| {
|
||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||
ui.label(
|
||
RichText::new(name)
|
||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||
.color(TEXT),
|
||
);
|
||
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
|
||
});
|
||
}
|
||
/// Full-window backdrop gradient used behind every editor.
|
||
pub fn backdrop(ui: &egui::Ui) {
|
||
ui.painter()
|
||
.add(gradient(ui.max_rect(), Color32::from_rgb(20, 21, 24), BG));
|
||
}
|
||
/// Horizontal level bar in dBFS (−60..0) with a hairline frame.
|
||
pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32) {
|
||
ui.horizontal(|ui| {
|
||
ui.add_sized(
|
||
Vec2::new(70., 16.),
|
||
egui::Label::new(RichText::new(label).size(11.).color(MUTED)),
|
||
);
|
||
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, 10.), Sense::hover());
|
||
let p = ui.painter();
|
||
p.rect_filled(rect, 1., SCREEN);
|
||
let t = ((db + 60.) / 60.).clamp(0., 1.);
|
||
let mut fill = rect;
|
||
fill.set_width(rect.width() * t);
|
||
p.rect_filled(fill, 1., color);
|
||
p.rect_stroke(
|
||
rect,
|
||
1.,
|
||
Stroke::new(1.0_f32, EDGE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
ui.label(
|
||
RichText::new(if db <= -60. {
|
||
"−∞".to_string()
|
||
} else {
|
||
format!("{db:.1} dB")
|
||
})
|
||
.monospace()
|
||
.size(12.)
|
||
.color(TEXT),
|
||
);
|
||
});
|
||
}
|