Add different wave and filter types
This commit is contained in:
41
crates/synth-core/src/audio_out.rs
Normal file
41
crates/synth-core/src/audio_out.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Audio output sink — final stage in the signal chain.
|
||||
//!
|
||||
//! In the patch bay this is the node you connect the last module to; it
|
||||
//! represents the physical speakers / DAC.
|
||||
|
||||
use crate::{AudioProcessor, config::SampleRate};
|
||||
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
|
||||
|
||||
pub struct AudioOut {
|
||||
pub level: f32,
|
||||
sample_rate: SampleRate,
|
||||
}
|
||||
|
||||
impl AudioOut {
|
||||
pub fn new(sample_rate: SampleRate) -> Self {
|
||||
Self { level: 0.8, sample_rate }
|
||||
}
|
||||
|
||||
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
|
||||
kind: "out",
|
||||
label: "Output",
|
||||
jacks: &[
|
||||
JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for AudioOut {
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
for s in out.iter_mut() {
|
||||
*s *= self.level;
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
let _ = self.sample_rate; // kept for API symmetry
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ pub struct ParamDescriptor {
|
||||
pub default: f32,
|
||||
/// Display unit string (e.g. `"Hz"`, `"s"`, `""`).
|
||||
pub unit: &'static str,
|
||||
/// When non-empty, this param is a discrete enum selector.
|
||||
/// Each entry is the display label for that step (step 0 = index 0, etc.).
|
||||
/// `min` should be 0.0 and `max` should be `(labels.len() - 1) as f32`.
|
||||
pub labels: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Full compile-time descriptor for a DSP component type.
|
||||
|
||||
@@ -34,10 +34,10 @@ impl Adsr {
|
||||
JackDescriptor { id: "env_out", label: "Env", direction: Direction::Output, signal: SignalKind::Cv },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s" },
|
||||
ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s" },
|
||||
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "" },
|
||||
ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s" },
|
||||
ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s", labels: &[] },
|
||||
ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s", labels: &[] },
|
||||
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "", labels: &[] },
|
||||
ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s", labels: &[] },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -40,9 +40,8 @@ impl Svf {
|
||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz" },
|
||||
ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "" },
|
||||
ParamDescriptor { id: "mode", label: "Mode", min: 0.0, max: 3.0, default: 0.0, unit: "" },
|
||||
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz", labels: &[] },
|
||||
ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ impl Lfo {
|
||||
JackDescriptor { id: "cv_out", label: "Out", direction: Direction::Output, signal: SignalKind::Cv },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz" },
|
||||
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "" },
|
||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "" },
|
||||
ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz", labels: &[] },
|
||||
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
|
||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod vca;
|
||||
pub mod lfo;
|
||||
pub mod midi;
|
||||
pub mod patch;
|
||||
pub mod audio_out;
|
||||
|
||||
pub use config::SampleRate;
|
||||
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
|
||||
|
||||
@@ -35,8 +35,8 @@ impl Vco {
|
||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz" },
|
||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "" },
|
||||
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz", labels: &[] },
|
||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Vca {
|
||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||
],
|
||||
params: &[
|
||||
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "" },
|
||||
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,18 +11,20 @@ use synth_core::filter::Svf;
|
||||
use synth_core::envelope::Adsr;
|
||||
use synth_core::vca::Vca;
|
||||
use synth_core::lfo::Lfo;
|
||||
use synth_core::audio_out::AudioOut;
|
||||
|
||||
// ── Component registry — single source of truth ──────────────────────────────
|
||||
|
||||
/// All available component types, sourced directly from synth-core.
|
||||
/// Adding a new component to synth-core and this array is all that's needed
|
||||
/// for it to appear in the patch bay palette.
|
||||
const REGISTRY: [ComponentDescriptor; 5] = [
|
||||
const REGISTRY: [ComponentDescriptor; 6] = [
|
||||
Vco::DESCRIPTOR,
|
||||
Svf::DESCRIPTOR,
|
||||
Adsr::DESCRIPTOR,
|
||||
Vca::DESCRIPTOR,
|
||||
Lfo::DESCRIPTOR,
|
||||
AudioOut::DESCRIPTOR,
|
||||
];
|
||||
|
||||
// ── Visual constants ──────────────────────────────────────────────────────────
|
||||
@@ -50,6 +52,7 @@ fn module_color(kind: &str) -> &'static str {
|
||||
"adsr" => "#2a9d3c",
|
||||
"vca" => "#c47a2e",
|
||||
"lfo" => "#2a8f9d",
|
||||
"out" => "#9d1515",
|
||||
_ => "#555555",
|
||||
}
|
||||
}
|
||||
@@ -116,6 +119,27 @@ impl Module {
|
||||
&& y >= self.y && y <= self.y + self.height()
|
||||
}
|
||||
|
||||
/// Hit test for parameter rows. Returns `(param_idx, current_val, min, max)`.
|
||||
fn hit_param(&self, x: f32, y: f32) -> Option<(usize, f32, f32, f32)> {
|
||||
let desc = self.descriptor();
|
||||
if desc.params.is_empty() { return None; }
|
||||
let tx = self.x + 8.0;
|
||||
let tw = MOD_W - 16.0;
|
||||
if x < tx || x > tx + tw { return None; }
|
||||
let n_in = desc.jacks.iter().filter(|j| j.direction == Direction::Input).count();
|
||||
let n_out = desc.jacks.iter().filter(|j| j.direction == Direction::Output).count();
|
||||
let jack_rows = n_in.max(n_out) as f32;
|
||||
let params_top = self.y + MOD_HEADER + jack_rows * JACK_ROW + 3.0;
|
||||
for (pi, p) in desc.params.iter().enumerate() {
|
||||
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
|
||||
if y >= py && y <= py + PARAM_ROW {
|
||||
let val = self.param_values.get(pi).copied().unwrap_or(p.default);
|
||||
return Some((pi, val, p.min, p.max));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> {
|
||||
let desc = self.descriptor();
|
||||
let mut in_idx = 0usize;
|
||||
@@ -159,6 +183,16 @@ enum DragState {
|
||||
cur_x: f32,
|
||||
cur_y: f32,
|
||||
},
|
||||
/// Dragging a parameter slider. `start_y` / `start_val` are anchored at
|
||||
/// the pointer-down position so the value is relative to where you clicked.
|
||||
Param {
|
||||
module_id: u32,
|
||||
param_idx: usize,
|
||||
start_y: f32,
|
||||
start_val: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
},
|
||||
}
|
||||
|
||||
// ── PatchBay ──────────────────────────────────────────────────────────────────
|
||||
@@ -171,6 +205,12 @@ pub struct PatchBay {
|
||||
cables: Vec<VisCable>,
|
||||
drag: DragState,
|
||||
next_id: u32,
|
||||
/// Incremented whenever the patch topology changes (modules added/removed,
|
||||
/// cables added/removed). JS polls this to know when to rebuild the audio graph.
|
||||
patch_version: u32,
|
||||
/// Incremented whenever a parameter value changes. JS polls this to update
|
||||
/// audio params without rebuilding the full graph.
|
||||
params_version: u32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -193,6 +233,8 @@ impl PatchBay {
|
||||
cables: Vec::new(),
|
||||
drag: DragState::Idle,
|
||||
next_id: 1,
|
||||
patch_version: 0,
|
||||
params_version: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +245,7 @@ impl PatchBay {
|
||||
self.next_id += 1;
|
||||
let param_values = desc.params.iter().map(|p| p.default).collect();
|
||||
self.modules.push(Module { id, kind: desc.kind, label: desc.label, x, y, param_values });
|
||||
self.patch_version += 1;
|
||||
id
|
||||
} else {
|
||||
0
|
||||
@@ -213,6 +256,21 @@ impl PatchBay {
|
||||
pub fn remove_module(&mut self, id: u32) {
|
||||
self.modules.retain(|m| m.id != id);
|
||||
self.cables.retain(|c| c.src_module != id && c.dst_module != id);
|
||||
self.patch_version += 1;
|
||||
}
|
||||
|
||||
/// Returns a counter that increments on every structural patch change
|
||||
/// (module added/removed, cable added/removed). JS polls this each frame
|
||||
/// to know when to rebuild the Web Audio graph.
|
||||
pub fn patch_version(&self) -> u32 {
|
||||
self.patch_version
|
||||
}
|
||||
|
||||
/// Returns a counter that increments whenever a parameter value changes.
|
||||
/// JS polls this each frame to apply live audio param updates without a
|
||||
/// full graph rebuild.
|
||||
pub fn params_version(&self) -> u32 {
|
||||
self.params_version
|
||||
}
|
||||
|
||||
/// Update a parameter value (param_idx is the position in the descriptor's params slice).
|
||||
@@ -220,6 +278,7 @@ impl PatchBay {
|
||||
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
|
||||
if param_idx < m.param_values.len() {
|
||||
m.param_values[param_idx] = value;
|
||||
self.params_version += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +314,43 @@ impl PatchBay {
|
||||
}
|
||||
}
|
||||
|
||||
// Parameter interaction (between jacks and header).
|
||||
// Enum params select a segment immediately on click; continuous params start a drag.
|
||||
#[derive(Clone)]
|
||||
enum ParamAction { Enum(u32, usize, usize), Continuous(u32, usize, f32, f32, f32) }
|
||||
let action = (0..n).rev().find_map(|i| {
|
||||
let m = &self.modules[i];
|
||||
m.hit_param(x, y).map(|(pidx, start_val, min, max)| {
|
||||
let desc = m.descriptor();
|
||||
let p = &desc.params[pidx];
|
||||
if !p.labels.is_empty() {
|
||||
let tx = m.x + 8.0;
|
||||
let tw = MOD_W - 16.0;
|
||||
let n_lbls = p.labels.len() as f32;
|
||||
let seg = ((x - tx) / tw * n_lbls).floor().clamp(0.0, n_lbls - 1.0) as usize;
|
||||
ParamAction::Enum(m.id, pidx, seg)
|
||||
} else {
|
||||
ParamAction::Continuous(m.id, pidx, start_val, min, max)
|
||||
}
|
||||
})
|
||||
});
|
||||
match action {
|
||||
Some(ParamAction::Enum(module_id, param_idx, seg)) => {
|
||||
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
|
||||
if param_idx < m.param_values.len() {
|
||||
m.param_values[param_idx] = seg as f32;
|
||||
self.params_version += 1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(ParamAction::Continuous(module_id, param_idx, start_val, min, max)) => {
|
||||
self.drag = DragState::Param { module_id, param_idx, start_y: y, start_val, min, max };
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Header drag
|
||||
for i in (0..n).rev() {
|
||||
if self.modules[i].hit_header(x, y) {
|
||||
@@ -284,6 +380,18 @@ impl PatchBay {
|
||||
cur_x: x, cur_y: y,
|
||||
};
|
||||
}
|
||||
DragState::Param { module_id, param_idx, start_y, start_val, min, max } => {
|
||||
// 120 px of travel = full range; drag up increases value
|
||||
let range = max - min;
|
||||
let delta = (start_y - y) / 120.0 * range;
|
||||
let new_val = (start_val + delta).clamp(min, max);
|
||||
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
|
||||
if param_idx < m.param_values.len() {
|
||||
m.param_values[param_idx] = new_val;
|
||||
self.params_version += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
DragState::Idle => {}
|
||||
}
|
||||
}
|
||||
@@ -313,6 +421,7 @@ impl PatchBay {
|
||||
dst_module: dst_m,
|
||||
dst_jack: dst_j,
|
||||
});
|
||||
self.patch_version += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -460,15 +569,38 @@ impl PatchBay {
|
||||
}
|
||||
|
||||
fn draw_modules(&self, ctx: &CanvasRenderingContext2d) {
|
||||
// If a param drag is active, extract which module/param is highlighted
|
||||
let active_param: Option<(u32, usize)> = match &self.drag {
|
||||
DragState::Param { module_id, param_idx, .. } => Some((*module_id, *param_idx)),
|
||||
_ => None,
|
||||
};
|
||||
for m in &self.modules {
|
||||
self.draw_module(ctx, m);
|
||||
let has_signal = self.cables.iter().any(|c| c.dst_module == m.id);
|
||||
let active_pidx = active_param.and_then(|(mid, pi)| if mid == m.id { Some(pi) } else { None });
|
||||
self.draw_module(ctx, m, has_signal, active_pidx);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module) {
|
||||
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option<usize>) {
|
||||
let desc = m.descriptor();
|
||||
let mh = m.height();
|
||||
let color = module_color(m.kind);
|
||||
let is_out = m.kind == "out";
|
||||
|
||||
// For the output node: draw a coloured outer glow ring first so it
|
||||
// sits behind the module body.
|
||||
if is_out {
|
||||
let glow = if has_signal { "#22c55e" } else { "#4a1010" };
|
||||
ctx.set_stroke_style_str(glow);
|
||||
ctx.set_line_width(if has_signal { 2.5 } else { 1.0 });
|
||||
rounded_rect(
|
||||
ctx,
|
||||
(m.x - 4.0) as f64, (m.y - 4.0) as f64,
|
||||
(MOD_W + 8.0) as f64, (mh + 8.0) as f64,
|
||||
10.0,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Body
|
||||
ctx.set_fill_style_str("#20242e");
|
||||
@@ -484,12 +616,36 @@ impl PatchBay {
|
||||
ctx.set_fill_style_str("rgba(255,255,255,0.95)");
|
||||
ctx.set_font("bold 12px monospace");
|
||||
ctx.set_text_align("center");
|
||||
let label_cx = if is_out { m.x + MOD_W * 0.38 } else { m.x + MOD_W * 0.5 };
|
||||
let _ = ctx.fill_text(
|
||||
m.label,
|
||||
(m.x + MOD_W * 0.5) as f64,
|
||||
label_cx as f64,
|
||||
(m.y + MOD_HEADER * 0.70) as f64,
|
||||
);
|
||||
|
||||
// Output node: LIVE / UNPATCHED badge on the right of the header
|
||||
if is_out {
|
||||
if has_signal {
|
||||
ctx.set_fill_style_str("#22c55e");
|
||||
ctx.set_font("bold 9px monospace");
|
||||
ctx.set_text_align("right");
|
||||
let _ = ctx.fill_text(
|
||||
"● LIVE",
|
||||
(m.x + MOD_W - 6.0) as f64,
|
||||
(m.y + MOD_HEADER * 0.70) as f64,
|
||||
);
|
||||
} else {
|
||||
ctx.set_fill_style_str("rgba(255,255,255,0.28)");
|
||||
ctx.set_font("9px monospace");
|
||||
ctx.set_text_align("right");
|
||||
let _ = ctx.fill_text(
|
||||
"unpatched",
|
||||
(m.x + MOD_W - 6.0) as f64,
|
||||
(m.y + MOD_HEADER * 0.70) as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Outer border
|
||||
ctx.set_stroke_style_str("rgba(255,255,255,0.07)");
|
||||
ctx.set_line_width(1.0);
|
||||
@@ -536,31 +692,72 @@ impl PatchBay {
|
||||
|
||||
// Parameters
|
||||
for (pi, p) in desc.params.iter().enumerate() {
|
||||
let is_active = active_param == Some(pi);
|
||||
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
|
||||
let val = m.param_values.get(pi).copied().unwrap_or(p.default);
|
||||
let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0);
|
||||
let tx = m.x + 8.0;
|
||||
let tw = MOD_W - 16.0;
|
||||
let track_y = py + 13.0;
|
||||
let tx = m.x + 8.0;
|
||||
let tw = MOD_W - 16.0;
|
||||
|
||||
// Track bg
|
||||
ctx.set_fill_style_str("#181b22");
|
||||
ctx.fill_rect(tx as f64, track_y as f64, tw as f64, 3.0);
|
||||
// Track fill
|
||||
ctx.set_fill_style_str(color);
|
||||
ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, 3.0);
|
||||
if !p.labels.is_empty() {
|
||||
// ── Enum selector: a row of clickable labelled segments ───────
|
||||
let n_segs = p.labels.len();
|
||||
let selected = val.round() as usize;
|
||||
let seg_w = tw / n_segs as f32;
|
||||
let btn_y = py + 2.0;
|
||||
let btn_h = PARAM_ROW - 4.0;
|
||||
|
||||
// Labels
|
||||
ctx.set_fill_style_str("rgba(160,165,180,0.8)");
|
||||
ctx.set_font("9px sans-serif");
|
||||
ctx.set_text_align("left");
|
||||
let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64);
|
||||
ctx.set_text_align("right");
|
||||
let _ = ctx.fill_text(
|
||||
&format_param(val, p.unit),
|
||||
(tx + tw) as f64,
|
||||
(py + 10.0) as f64,
|
||||
);
|
||||
ctx.set_font("bold 8px monospace");
|
||||
for (li, lbl) in p.labels.iter().enumerate() {
|
||||
let bx = tx + li as f32 * seg_w;
|
||||
let bw = seg_w - 1.0;
|
||||
if li == selected {
|
||||
ctx.set_fill_style_str(color);
|
||||
} else {
|
||||
ctx.set_fill_style_str("#181b22");
|
||||
}
|
||||
ctx.fill_rect(bx as f64, btn_y as f64, bw as f64, btn_h as f64);
|
||||
ctx.set_fill_style_str(if li == selected {
|
||||
"rgba(255,255,255,0.95)"
|
||||
} else {
|
||||
"rgba(140,145,160,0.7)"
|
||||
});
|
||||
ctx.set_text_align("center");
|
||||
let _ = ctx.fill_text(
|
||||
lbl,
|
||||
(bx + bw * 0.5) as f64,
|
||||
(btn_y + btn_h * 0.72) as f64,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// ── Continuous slider ─────────────────────────────────────────
|
||||
let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0);
|
||||
let track_y = py + 13.0;
|
||||
|
||||
// Active row highlight
|
||||
if is_active {
|
||||
ctx.set_fill_style_str("rgba(255,255,255,0.05)");
|
||||
ctx.fill_rect(m.x as f64, py as f64, MOD_W as f64, PARAM_ROW as f64);
|
||||
}
|
||||
|
||||
// Track bg
|
||||
ctx.set_fill_style_str("#181b22");
|
||||
ctx.fill_rect(tx as f64, track_y as f64, tw as f64, if is_active { 4.0 } else { 3.0 });
|
||||
// Track fill
|
||||
ctx.set_fill_style_str(if is_active { "rgba(255,255,255,0.85)" } else { color });
|
||||
ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, if is_active { 4.0 } else { 3.0 });
|
||||
|
||||
// Labels
|
||||
ctx.set_fill_style_str(if is_active { "rgba(230,235,255,1.0)" } else { "rgba(160,165,180,0.8)" });
|
||||
ctx.set_font(if is_active { "bold 9px sans-serif" } else { "9px sans-serif" });
|
||||
ctx.set_text_align("left");
|
||||
let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64);
|
||||
ctx.set_text_align("right");
|
||||
let _ = ctx.fill_text(
|
||||
&format_param(val, p.unit),
|
||||
(tx + tw) as f64,
|
||||
(py + 10.0) as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user