Files
bass-foundry/plugins/patina/ui/src/lib.rs
T
nvrl f36ff0780c
Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
feat: add Anvil sub anchor and Crucible resampler
Two new CLAP effects aimed at riddim and dubstep production, plus shared
crates so all four plugins keep one look and reuse DSP.

Anvil 0.1.0 (io.storyboard.sub-anchor):
- Replaces the band below Split with a clean mono sine. Every note gets
  the same peak level and restarts at the same phase, so switch-ups keep
  their weight and kick hits line up.
- Pitch and note starts come from the audio (YIN, 80 ms lookahead
  reported to the host) or from MIDI notes sent to the effect.
- Dynamics, Drive, Octave, Glide, Phase, Gate and Keep; Solo sub monitor,
  note readout, level meters and a low-band oscilloscope.

Crucible 0.1.0 (io.storyboard.resampler):
- Records its input continuously in a lock-free ring with host transport
  markers. Capture takes the newest 1-16 complete bars on the bar grid,
  following tempo changes, or the newest audio when stopped.
- Grid (straight and triplet) or transient slicing, manual cuts, slice
  audition without audio-thread allocation.
- Export writes the loop, trimmed, declicked and normalised slices named
  and tagged with their root note, and an optional 2048-sample wavetable
  for Serum and Vital.

Shared:
- common-ui holds the palette, fonts, knobs and panels taken from Patina;
  Patina renders identically. Rotary knobs at their minimum no longer
  tessellate a zero-length arc into NaN vertices.
- common-dsp holds biquads, a Linkwitz-Riley crossover, YIN, note helpers
  and a float WAV writer with sampler root and Serum frame chunks.
- Catalog, CI matrix, release tags, editor previews and docs cover both
  plugins; a script test keeps the catalog and workflows in step.
2026-11-06 03:22:00 +01:00

752 lines
27 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 native editor, also exercised by the headless renderer.
pub use common_ui::style;
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
use std::time::{Duration, Instant};
pub struct State {
pub delta: bool,
pub selected: Option<usize>,
pub preset: usize,
pub snapshots: [Settings; 2],
pub active: usize,
pub analyzer: AnalyzerFrame,
pub gesture_active: bool,
pub gestures: [bool; 30],
last_audio: Instant,
pub help: String,
drag: Option<usize>,
}
impl Default for State {
fn default() -> Self {
Self {
delta: false,
selected: None,
preset: 0,
snapshots: [Settings::default(); 2],
active: 0,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
gestures: [false; 30],
last_audio: Instant::now(),
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
drag: None,
}
}
}
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
// Display-only smoothing; it never feeds the detector.
for i in 0..GRID {
self.analyzer.input[i] += 0.45 * (frame.input[i] - self.analyzer.input[i]);
self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]);
self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]);
self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]);
}
self.analyzer.focus = frame.focus;
self.analyzer.input_peak = frame.input_peak;
self.analyzer.output_peak = frame.output_peak;
self.analyzer.guard = frame.guard;
self.analyzer.sample_rate = frame.sample_rate;
self.analyzer.latency = frame.latency;
self.analyzer.channels = frame.channels;
if frame.input_peak > 1e-6
|| frame.output_peak > 1e-6
|| frame.reduction.iter().any(|v| *v > 0.001)
{
self.last_audio = Instant::now();
}
}
}
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
/// Shows a help line in the status bar while the knob is hovered or focused.
fn hint(response: egui::Response, state: &mut State, text: &str) {
if response.hovered() || response.has_focus() {
state.help = text.into();
}
}
fn note(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).color(MUTED));
}
#[allow(clippy::too_many_arguments)] // Explicit widget metadata keeps the control table readable.
fn control(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
unit: &str,
log: bool,
help: &str,
state: &mut State,
) {
ui.vertical(|ui| {
caption(ui, &name.to_uppercase());
let previous = *value;
let response = ui.add(
egui::Slider::new(value, range)
.logarithmic(log)
.suffix(unit)
.clamping(egui::SliderClamping::Always)
.smart_aim(false),
);
if response.dragged() && ui.input(|i| i.modifiers.shift) {
*value = previous + (*value - previous) * 0.1;
}
if response.double_clicked() {
let defaults = Settings::default();
*value = match name {
"Transient guard" => defaults.transient_guard,
"Attack" => defaults.attack,
"Release" => defaults.release,
"Low frequency" => defaults.low,
"High frequency" => defaults.high,
"Mix" => defaults.mix,
_ => *value,
};
}
if response.hovered() || response.has_focus() {
state.help = help.into();
}
});
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
let before = *settings;
let full = ui.max_rect();
ui.painter()
.add(gradient(full, Color32::from_rgb(20, 21, 24), BG));
ui.add_space(2.);
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("PATINA")
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
.color(TEXT),
);
ui.label(RichText::new("RESONANCE SUPPRESSOR").size(12.).color(MUTED));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.")
.clicked()
{
settings.bypass = !settings.bypass;
}
if ui
.selectable_label(state.delta, RichText::new("DELTA").size(13.))
.on_hover_text(
"Hear only what is removed: delayed dry minus wet, before output trim.",
)
.clicked()
{
state.delta = !state.delta;
}
ui.separator();
if ui.button(RichText::new("COPY A TO B").size(13.)).clicked() {
state.snapshots[state.active] = *settings;
state.snapshots[1] = state.snapshots[0];
if state.active == 1 {
*settings = state.snapshots[1];
}
}
for i in (0..2).rev() {
if ui
.selectable_label(
state.active == i,
RichText::new(if i == 0 { "A" } else { "B" }).size(13.),
)
.clicked()
&& state.active != i
{
state.snapshots[state.active] = *settings;
*settings = state.snapshots[i];
state.active = i;
}
}
ui.separator();
egui::ComboBox::from_id_salt("resonance-preset")
.selected_text(
(0..4)
.find(|i| *settings == Settings::preset(*i))
.map_or("Custom", |i| Settings::PRESETS[i]),
)
.width(155.)
.show_ui(ui, |ui| {
for (i, name) in Settings::PRESETS.iter().enumerate() {
if ui.selectable_value(&mut state.preset, i, *name).clicked() {
*settings = Settings::preset(i);
state.delta = false;
}
}
});
});
});
ui.add_space(6.);
ui.horizontal(|ui| {
let input = db(state.analyzer.input_peak);
let output = db(state.analyzer.output_peak);
let max = state
.analyzer
.reduction
.iter()
.copied()
.fold(0f32, f32::max);
readout(ui, "IN", format!("{input:>6.1} dBFS"), STEEL);
readout(
ui,
"OUT",
format!("{output:>6.1} dBFS"),
if output >= 0. { ACCENT } else { TEXT },
);
readout(ui, "CUT", format!("{max:>4.1} dB"), ACCENT);
readout(
ui,
"GUARD",
format!("{:>3.0} %", state.analyzer.guard * 100.),
MUTED,
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
caption(
ui,
&format!(
"{:.1} ms latency · {}",
state.analyzer.latency as f32 / state.analyzer.sample_rate * 1000.,
if state.analyzer.channels == 1 {
"Mono"
} else {
"Stereo linked"
},
),
);
});
});
ui.add_space(6.);
let body_height = (ui.available_height() - 40.).max(480.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(176.);
ui.set_min_height(body_height - 28.);
ui.vertical_centered(|ui| {
caption(ui, "DETECTION");
ui.add_space(4.);
let defaults = Settings::default();
let knob = rotary(
ui,
"Depth",
&mut settings.depth,
0. ..=20.,
defaults.depth,
" dB",
108.,
ACCENT,
);
hint(knob, state, "Largest cut. 0 dB does nothing.");
ui.add_space(8.);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
let knob = rotary(
ui,
"Sharpness",
&mut settings.sharpness,
0. ..=100.,
defaults.sharpness,
" %",
58.,
ACCENT,
);
hint(knob, state, "Low: smooth broad bumps. High: narrow rings.");
});
columns[1].vertical_centered(|ui| {
let knob = rotary(
ui,
"Selectivity",
&mut settings.selectivity,
0. ..=100.,
defaults.selectivity,
" %",
58.,
ACCENT,
);
hint(knob, state, "Low: treat any bump. High: only the worst.");
});
});
ui.add_space(10.);
ui.separator();
ui.add_space(4.);
caption(ui, "OUTPUT");
ui.add_space(4.);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
rotary(
ui,
"Mix",
&mut settings.mix,
0. ..=100.,
100.,
" %",
58.,
STEEL,
);
});
columns[1].vertical_centered(|ui| {
rotary(
ui,
"Output",
&mut settings.output,
-12. ..=12.,
0.,
" dB",
58.,
STEEL,
);
});
});
});
});
ui.add_space(4.);
ui.vertical(|ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().slider_width = ((ui.available_width() - 48.) / 4. - 103.).max(72.);
spectrum(ui, settings, state);
ui.add_space(4.);
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("FOCUS / PROTECT").size(11.).color(FOCUS));
for i in 0..4 {
let f = settings.focus[i];
if ui
.selectable_label(
state.selected == Some(i),
format!("{} {}", i + 1, if f.enabled { "●" } else { "○" }),
)
.clicked()
{
state.selected = Some(i);
}
}
if let Some(i) = state.selected {
let f = &mut settings.focus[i];
ui.checkbox(&mut f.enabled, "Enabled");
ui.label("Hz");
ui.add(
egui::DragValue::new(&mut f.frequency)
.range(20. ..=20000.)
.speed(5.),
);
ui.label("Cut more (+) / protect (−)");
ui.add(
egui::DragValue::new(&mut f.sensitivity)
.range(-18. ..=18.)
.speed(0.1)
.suffix(" dB"),
);
ui.label("Width");
ui.add(
egui::DragValue::new(&mut f.width)
.range(0.05..=2.)
.speed(0.01)
.suffix(" oct"),
);
if ui.small_button("Reset").clicked() {
f.sensitivity = 0.;
}
if ui.small_button("Remove").clicked() {
*f = Focus::default();
state.selected = None;
}
} else {
note(
ui,
"Double-click the spectrum to place a point. Drag up to cut more there, down to protect.",
);
}
});
ui.add_space(8.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
egui::Grid::new("engine-controls")
.num_columns(3)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 32.) / 3.)
.show(ui, |ui| {
control(
ui,
"Attack",
&mut settings.attack,
1. ..=200.,
" ms",
true,
"How fast cuts grow. Short catches brief whistles.",
state,
);
control(
ui,
"Release",
&mut settings.release,
10. ..=1000.,
" ms",
true,
"How fast cuts recover after a resonance stops.",
state,
);
control(
ui,
"Transient guard",
&mut settings.transient_guard,
0. ..=100.,
" %",
false,
"Relaxes cuts on attacks to keep punch. Turn down to catch short rings.",
state,
);
ui.end_row();
});
ui.add_space(6.);
egui::Grid::new("range-controls")
.num_columns(2)
.spacing([22., 8.])
.show(ui, |ui| {
control(
ui,
"Low frequency",
&mut settings.low,
20. ..=19999.,
" Hz",
true,
"Lower processing boundary. Protect the source's body.",
state,
);
control(
ui,
"High frequency",
&mut settings.high,
21. ..=20000.,
" Hz",
true,
"Upper processing boundary, also limited by Nyquist.",
state,
);
});
});
});
});
ui.separator();
note(ui, &state.help);
if settings.low >= settings.high {
if settings.low != before.low {
settings.low = (settings.high - 1.).max(20.);
} else {
settings.high = (settings.low + 1.).min(20000.);
}
}
state.gesture_active = ui.input(|i| i.pointer.any_down());
if state.last_audio.elapsed() < Duration::from_millis(300) {
ui.ctx().request_repaint_after(Duration::from_millis(33));
}
}
fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let height = (ui.available_height() - 290.).clamp(180., 540.);
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter();
p.rect_filled(outer, 3., SCREEN);
p.rect_stroke(
outer,
3.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
let r = Rect::from_min_max(
outer.min + Vec2::new(46., 26.),
outer.max - Vec2::new(42., 26.),
);
let x = |f: f32| r.left() + ((f.max(20.) / 20.).ln() / 1000f32.ln()).clamp(0., 1.) * r.width();
let y = |d: f32| r.bottom() - (d + 100.).clamp(0., 100.) / 100. * r.height();
let red_y = |d: f32| r.top() + d / 20. * r.height() * 0.75;
for (xx, level, color) in [
(outer.left() + 5., state.analyzer.input_peak, STEEL),
(
outer.right() - 9.,
state.analyzer.output_peak,
if state.analyzer.output_peak >= 1. {
ACCENT
} else {
TEXT
},
),
] {
let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom()));
p.rect_filled(rail, 1., Color32::from_rgb(26, 28, 32));
let height = ((db(level) + 72.) / 72.).clamp(0., 1.) * rail.height();
p.rect_filled(
Rect::from_min_max(Pos2::new(xx, rail.bottom() - height), rail.max),
2.,
color.gamma_multiply(0.75),
);
}
let color_grid = Color32::from_rgb(30, 32, 36);
for f in [
20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000.,
] {
let xx = x(f);
p.line_segment(
[Pos2::new(xx, r.top()), Pos2::new(xx, r.bottom())],
Stroke::new(1.0_f32, color_grid),
);
let label = if f >= 1000. {
format!("{}k", f / 1000.)
} else {
format!("{f:.0}")
};
p.text(
Pos2::new(xx, r.bottom() + 7.),
egui::Align2::CENTER_TOP,
label,
FontId::proportional(12.),
MUTED,
);
}
for d in [-96., -72., -48., -24., 0.] {
p.line_segment(
[Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))],
Stroke::new(1.0_f32, color_grid),
);
p.text(
Pos2::new(r.left() - 7., y(d)),
egui::Align2::RIGHT_CENTER,
format!("{d:.0}"),
FontId::proportional(12.),
MUTED,
);
}
for d in [0., 5., 10., 15., 20.] {
p.text(
Pos2::new(r.right() + 7., red_y(d)),
egui::Align2::LEFT_CENTER,
format!("−{d:.0}"),
FontId::proportional(12.),
ACCENT,
);
}
p.text(
r.left_top() - Vec2::new(30., 19.),
egui::Align2::LEFT_TOP,
"dBFS / bin",
FontId::proportional(12.),
MUTED,
);
p.text(
r.right_top() + Vec2::new(30., -19.),
egui::Align2::RIGHT_TOP,
"Cut dB",
FontId::proportional(12.),
ACCENT,
);
p.rect_filled(
Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())),
0.,
Color32::from_black_alpha(120),
);
p.rect_filled(
Rect::from_min_max(Pos2::new(x(settings.high), r.top()), r.max),
0.,
Color32::from_black_alpha(120),
);
let signal: Vec<_> = (0..GRID)
.map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i])))
.collect();
let mut source_fill = egui::Mesh::default();
for (i, point) in signal.iter().enumerate() {
source_fill.colored_vertex(
Pos2::new(point.x, r.bottom()),
Color32::from_rgba_unmultiplied(184, 192, 204, 4),
);
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(184, 192, 204, 34));
if i > 0 {
let j = (i * 2) as u32;
source_fill.add_triangle(j - 2, j - 1, j);
source_fill.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(source_fill));
p.add(egui::Shape::line(
signal,
Stroke::new(1.2_f32, STEEL.gamma_multiply(0.7)),
));
// What the detector hears (envelope) and what it expects (reference).
// Accent cuts appear where the envelope rises above the reference.
for (values, color, width) in [
(&state.analyzer.reference, DIM, 1.0_f32),
(&state.analyzer.envelope, TEXT.gamma_multiply(0.8), 1.2_f32),
] {
let line: Vec<_> = (0..GRID)
.map(|i| Pos2::new(x(frequency(i)), y(values[i])))
.collect();
p.add(egui::Shape::line(line, Stroke::new(width, color)));
}
let mut fill = Vec::with_capacity(GRID + 2);
fill.push(r.left_top());
for i in 0..GRID {
fill.push(Pos2::new(
x(frequency(i)),
red_y(state.analyzer.reduction[i]),
));
}
fill.push(r.right_top());
// A triangle strip makes non-convex reduction envelopes render accurately.
let mut mesh = egui::Mesh::default();
for i in 0..GRID {
let xx = x(frequency(i));
let yy = red_y(state.analyzer.reduction[i]);
mesh.colored_vertex(
Pos2::new(xx, r.top()),
Color32::from_rgba_unmultiplied(255, 1, 96, 6),
);
mesh.colored_vertex(
Pos2::new(xx, yy),
Color32::from_rgba_unmultiplied(255, 1, 96, 100),
);
if i > 0 {
let j = (i * 2) as u32;
mesh.add_triangle(j - 2, j - 1, j);
mesh.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(mesh));
p.add(egui::Shape::line(
fill[1..=GRID].to_vec(),
Stroke::new(1.6_f32, ACCENT),
));
let center = r.center().y;
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
let points: Vec<_> = (0..GRID)
.map(|i| {
Pos2::new(
x(frequency(i)),
focus_y(focus_offset(frequency(i), &settings.focus)),
)
})
.collect();
p.line_segment(
[Pos2::new(r.left(), center), Pos2::new(r.right(), center)],
Stroke::new(0.7_f32, FOCUS.gamma_multiply(0.3)),
);
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, FOCUS)));
for (i, f) in settings.focus.iter().enumerate().filter(|(_, f)| f.enabled) {
let pos = Pos2::new(x(f.frequency), focus_y(f.sensitivity));
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, FOCUS);
if state.selected == Some(i) {
p.circle_stroke(pos, 10., Stroke::new(1.0_f32, FOCUS.gamma_multiply(0.6)));
}
p.text(
pos - Vec2::new(0., 12.),
egui::Align2::CENTER_BOTTOM,
format!("{}", i + 1),
FontId::proportional(12.),
TEXT,
);
}
if state.analyzer.input_peak < 1e-6 {
p.text(
r.center() + Vec2::new(0., 42.),
egui::Align2::CENTER_CENTER,
"Play audio to inspect its resonances",
FontId::proportional(18.),
MUTED,
);
}
if let Some(pos) = response.hover_pos() {
if r.contains(pos) {
let f = 20. * 1000f32.powf((pos.x - r.left()) / r.width());
p.line_segment(
[Pos2::new(pos.x, r.top()), Pos2::new(pos.x, r.bottom())],
Stroke::new(0.7_f32, TEXT.gamma_multiply(0.3)),
);
response.clone().on_hover_text(format!("{f:.0} Hz • Double-click to add focus; drag a node to tame/protect; wheel sets width"));
if response.double_clicked() {
if let Some(i) = settings.focus.iter().position(|p| !p.enabled) {
settings.focus[i] = Focus {
enabled: true,
frequency: f,
sensitivity: 0.,
..Default::default()
};
state.selected = Some(i);
}
}
}
}
if response.drag_started() {
if let Some(pos) = response.interact_pointer_pos() {
state.drag = settings
.focus
.iter()
.enumerate()
.filter(|(_, f)| f.enabled)
.find(|(_, f)| {
Pos2::new(x(f.frequency), focus_y(f.sensitivity)).distance(pos) < 18.
})
.map(|(i, _)| i);
if state.drag.is_some() {
state.selected = state.drag;
} else if (pos.x - x(settings.low)).abs() < 12. {
state.drag = Some(4);
} else if (pos.x - x(settings.high)).abs() < 12. {
state.drag = Some(5);
}
}
}
if response.dragged() {
if let (Some(i), Some(pos)) = (state.drag, response.interact_pointer_pos()) {
let fine = ui.input(|i| i.modifiers.shift);
let f = (20. * 1000f32.powf((pos.x - r.left()) / r.width())).clamp(20., 20000.);
if i < 4 {
let node = &mut settings.focus[i];
let next = ((center - pos.y) / (r.height() * 0.65) * 36.).clamp(-18., 18.);
if fine {
node.frequency += (f - node.frequency) * 0.1;
node.sensitivity += (next - node.sensitivity) * 0.1;
} else {
node.frequency = f;
node.sensitivity = next;
}
} else if i == 4 {
settings.low = f.min(settings.high - 1.);
} else {
settings.high = f.max(settings.low + 1.);
}
}
}
if response.drag_stopped() {
state.drag = None;
}
if response.hovered() {
if let Some(i) = state.selected {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0. {
settings.focus[i].width =
(settings.focus[i].width + scroll * 0.002).clamp(0.05, 2.);
}
}
}
for f in [settings.low, settings.high] {
p.line_segment(
[Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())],
Stroke::new(1.0_f32, MUTED.gamma_multiply(0.7)),
);
let handle = Rect::from_center_size(Pos2::new(x(f), r.center().y), Vec2::new(12., 30.));
p.rect_filled(handle, 2., Color32::from_rgb(46, 49, 55));
p.rect_stroke(
handle,
6.,
Stroke::new(1.0_f32, STEEL.gamma_multiply(0.5)),
egui::StrokeKind::Inside,
);
}
}