Files
bass-foundry/plugins/common/ui/src/lib.rs
T
nvrl 01fd3241eb feat(ui): MAIN and ADVANCED pages, Anvil main page around Mode, Split and Sub level
Producers found the panels crowded: every control had the same weight. The
header now carries a MAIN / ADVANCED switch (common-ui header_paged). The
main page keeps the controls a first result needs; the advanced page shows
everything, as before.

- Anvil: Mode (Replace or Enhance), Split and Sub level, plus Octave, Ghost,
  Keep and Output. Kick lock, Dynamics, Drive, Glide, Phase, Gate and the
  pitch switches move to Advanced. The note readout says when the original
  sub plays because tracking is unsure.
- Lathe, Temper, Knurl, Planish, Braze: tracking and fine-tuning controls
  (Gate, Smooth, Snap, Harmonics, Push at, Floor, Bright, Normalize, band
  gains, Phase) move to Advanced. Lathe labels the octaves of the note in Hz
  on the harmonic axis.
- Forge passes the page to its module pages.
- Weld: the main page shows the level won back at full height, Mode,
  Response and Output; the phase plot, Sections, Low, High and Gate move to
  Advanced.
- editor-check renders the advanced pages with --advanced.
2026-11-06 21:16:11 +01:00

594 lines
21 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 common_dsp::note_name;
use common_dsp::tracker::{Pitch, PitchMode, Scale, NOTE_MAX, NOTE_MIN};
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)) {
header_paged(ui, name, subtitle, None, right);
}
/// Product header with the MAIN / ADVANCED page switch after the subtitle.
pub fn header_paged(
ui: &mut egui::Ui,
name: &str,
subtitle: &str,
advanced: Option<&mut bool>,
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));
if let Some(advanced) = advanced {
ui.add_space(14.);
page_switch(ui, advanced);
}
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),
);
});
}
const PITCH_CLASSES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
fn scale_name(scale: Scale) -> &'static str {
match scale {
Scale::Free => "off",
Scale::Minor => "minor",
Scale::Major => "major",
Scale::Root => "root only",
}
}
/// Short status of the pitch source for readout lines: "detecting pitch in F minor".
pub fn pitch_status(mode: PitchMode, pitch: &Pitch) -> String {
match (mode, pitch.scale) {
(PitchMode::Audio, Scale::Free) => "detecting pitch".into(),
(PitchMode::Audio, s) => format!(
"detecting pitch, key {} {}",
PITCH_CLASSES[pitch.note as usize % 12],
scale_name(s)
),
(PitchMode::Midi, _) => "following MIDI".into(),
(PitchMode::Note, _) => format!("fixed on {}", note_name(pitch.note)),
}
}
/// Pitch source switch for a header's right-to-left row: AUDIO, MIDI and NOTE, then a button
/// that opens Note mode's note, or the key lock and Follow bends for detection.
pub fn pitch_from(ui: &mut egui::Ui, mode: &mut PitchMode, pitch: &mut Pitch) {
let id = ui.make_persistent_id("pitch-detail");
let detail = match *mode {
PitchMode::Note => Some(note_name(pitch.note)),
PitchMode::Audio => Some(match pitch.scale {
Scale::Free if pitch.bends => "BENDS".into(),
Scale::Free => "KEY OFF".into(),
s => format!(
"{} {}",
PITCH_CLASSES[pitch.note as usize % 12],
match s {
Scale::Minor => "MIN",
Scale::Major => "MAJ",
_ => "ROOT",
}
),
}),
PitchMode::Midi => None,
};
if let Some(detail) = detail {
let help = if *mode == PitchMode::Note {
"The note this plugin follows. Nothing is detected: set the drop's root."
} else {
"Key lock and Follow bends for detection."
};
let r = ui
.button(RichText::new(detail).size(13.).color(ACCENT))
.on_hover_text(help);
if r.clicked() {
ui.memory_mut(|m| m.toggle_popup(id));
}
let note_mode = *mode == PitchMode::Note;
egui::popup_below_widget(
ui,
id,
&r,
egui::PopupCloseBehavior::CloseOnClickOutside,
|ui| {
ui.set_min_width(220.);
if note_mode {
pitch_note(ui, pitch, true);
} else {
caption(ui, "KEY LOCK");
ui.horizontal(|ui| {
for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] {
let help = match s {
Scale::Free => "Any pitch, also between notes.",
Scale::Minor => "Only notes of the natural minor scale.",
Scale::Major => "Only notes of the major scale.",
Scale::Root => "Only the root: detection picks the octave.",
};
let label = RichText::new(scale_name(s).to_uppercase()).size(12.);
if ui
.selectable_label(pitch.scale == s, label)
.on_hover_text(help)
.clicked()
{
pitch.scale = s;
}
}
});
if pitch.scale != Scale::Free {
ui.add_space(4.);
caption(ui, "ROOT");
pitch_note(ui, pitch, false);
}
ui.add_space(6.);
ui.checkbox(&mut pitch.bends, "Follow bends").on_hover_text(
"Retune on every bend and switch notes within 10 ms. Fast, but it \
flickers on growls. Off holds each note until a new one is clear.",
);
}
},
);
}
for (m, label, help) in [
(
PitchMode::Note,
"NOTE",
"You set the note; the audio only starts and stops it. Nothing is detected.",
),
(
PitchMode::Midi,
"MIDI",
"Notes from MIDI input. In FL, set the channel's MIDI output port and this \
effect's input port to the same number.",
),
(
PitchMode::Audio,
"AUDIO",
"Detect the pitch from the audio. When detection is unsure, tracked processing \
steps back instead of guessing.",
),
] {
let r = ui
.selectable_label(*mode == m, RichText::new(label).size(13.))
.on_hover_text(help);
if r.clicked() {
*mode = m;
}
}
caption(ui, "PITCH FROM");
}
/// Twelve pitch-class buttons, and octave steps when `octave` is set.
fn pitch_note(ui: &mut egui::Ui, pitch: &mut Pitch, octave: bool) {
let class = pitch.note % 12;
let base = pitch.note - class;
for row in [0..6, 6..12] {
ui.horizontal(|ui| {
for c in row {
let r = ui.add_sized(
[30., 22.],
egui::SelectableLabel::new(class == c as u8, PITCH_CLASSES[c]),
);
if r.clicked() {
pitch.note = (base + c as u8).clamp(NOTE_MIN, NOTE_MAX);
}
}
});
}
if octave {
ui.horizontal(|ui| {
caption(ui, "OCTAVE");
if ui.button("−").clicked() && pitch.note >= NOTE_MIN + 12 {
pitch.note -= 12;
}
ui.label(
RichText::new(format!("{}", pitch.note as i32 / 12 - 1))
.monospace()
.color(TEXT),
);
if ui.button("+").clicked() && pitch.note + 12 <= NOTE_MAX {
pitch.note += 12;
}
ui.label(RichText::new(note_name(pitch.note)).color(ACCENT));
});
}
}
/// MAIN / ADVANCED page switch. The main page holds the few controls a first result needs;
/// the advanced page holds everything else. Returns true while the advanced page shows.
pub fn page_switch(ui: &mut egui::Ui, advanced: &mut bool) -> bool {
ui.horizontal(|ui| {
for (page, label, help) in [
(
false,
"MAIN",
"The controls that matter for a first result.",
),
(true, "ADVANCED", "Every control, for fine tuning."),
] {
let r = ui
.selectable_label(*advanced == page, RichText::new(label).size(12.))
.on_hover_text(help);
if r.clicked() {
*advanced = page;
}
}
});
*advanced
}
/// Choice drawn as large chips, centred, for a plugin's mode on its main page.
pub fn choice<T: PartialEq + Copy>(
ui: &mut egui::Ui,
value: &mut T,
options: &[(T, &str, &str)],
) -> Option<egui::Response> {
let mut hovered = None;
let n = options.len() as f32;
let width = n * 96. + (n - 1.) * ui.spacing().item_spacing.x;
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - width) / 2.).max(0.));
for (v, label, help) in options {
let r = ui
.add_sized(
[96., 30.],
egui::SelectableLabel::new(*value == *v, RichText::new(*label).size(14.)),
)
.on_hover_text(*help);
if r.clicked() {
*value = *v;
}
if r.hovered() {
hovered = Some(r);
}
}
});
hovered
}