Files
bass-foundry/plugins/common/ui/src/lib.rs
T
nvrl 9ca4e4f9dd feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
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.
2026-11-06 17:29:54 +01:00

362 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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),
);
});
}