//! 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, 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, default: f32, size: f32, color: Color32, format: impl Fn(f32) -> String, parse: impl Fn(&str) -> Option, ) -> 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, 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(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( ui: &mut egui::Ui, value: &mut T, options: &[(T, &str, &str)], ) -> Option { 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 }