Add popup help to elements

This commit is contained in:
2026-03-27 08:29:11 +00:00
parent b8d3dc48ab
commit ea381fe433
11 changed files with 233 additions and 4 deletions

View File

@@ -10,7 +10,8 @@
"Bash(/Users/mattsp/projects/sound/build-web.sh)", "Bash(/Users/mattsp/projects/sound/build-web.sh)",
"Bash(wasm-pack build:*)", "Bash(wasm-pack build:*)",
"Read(//Users/mattsp/projects/sound/**)", "Read(//Users/mattsp/projects/sound/**)",
"Bash(./build-web.sh)" "Bash(./build-web.sh)",
"Bash(python3 -c \"import sys,json; [print\\(json.loads\\(l\\).keys\\(\\)\\) for l in sys.stdin]\")"
] ]
} }
} }

View File

@@ -25,6 +25,16 @@ impl AudioOut {
params: &[ params: &[
ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] }, ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] },
], ],
description: "\
## Output — Audio Sink
The final destination in the signal chain, representing the speakers or DAC.
Connect the last audio module's output jack to **In**. The green **● LIVE** badge lights up when a cable is connected and audio is flowing.
**Level** controls the master output volume.
> The oscilloscope and spectrum analyser always reflect what arrives at this node.",
}; };
} }

View File

@@ -71,4 +71,6 @@ pub struct ComponentDescriptor {
pub jacks: &'static [JackDescriptor], pub jacks: &'static [JackDescriptor],
/// Ordered list of tunable parameters. /// Ordered list of tunable parameters.
pub params: &'static [ParamDescriptor], pub params: &'static [ParamDescriptor],
/// Markdown-formatted help text shown in the patch-bay tooltip.
pub description: &'static str,
} }

View File

@@ -39,6 +39,19 @@ impl Adsr {
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "", 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: &[] }, ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s", labels: &[] },
], ],
description: "\
## ADSR — Envelope Generator
Produces a CV signal (01) that shapes amplitude over time in four stages:
- **Attack** — time to rise from 0 to peak after gate opens
- **Decay** — time to fall from peak to sustain level
- **Sustain** — level held while gate remains open
- **Release** — time to fall back to 0 after gate closes
**This module outputs CV, not audio.** Connect **Env** to a VCA `cv_gain` jack to shape volume, or to `cv_cutoff_hz` on the Filter for a filter envelope.
> **Tip:** VCO Out → VCA In, Keyboard Gate → ADSR Gate, ADSR Env → VCA cv_gain, VCA Out → Output In",
}; };
pub fn gate_on(&mut self) { self.stage = Stage::Attack; } pub fn gate_on(&mut self) { self.stage = Stage::Attack; }

View File

@@ -42,6 +42,16 @@ impl Svf {
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz", labels: &[] }, 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: &[] }, ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
], ],
description: "\
## Filter (SVF) — State-Variable Filter
2-pole filter that shapes the tonal colour of an audio signal.
**In** accepts audio; **Out** emits the filtered result.
**Cutoff** sets the corner frequency — frequencies above this point are attenuated (in low-pass mode). **Res** (resonance) narrows the peak; at 1.0 the filter self-oscillates.
Connect an LFO to `cv_cutoff_hz` for a sweeping wah effect, or an ADSR for a filter envelope.",
}; };
#[inline] #[inline]

View File

@@ -31,6 +31,18 @@ impl Lfo {
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "", 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"] }, ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
], ],
description: "\
## LFO — Low-Frequency Oscillator
Like a VCO but running at sub-audio speeds (0.0120 Hz). Outputs a CV signal (1 to +1) for modulation.
**Rate** controls oscillation speed. **Depth** scales the output amplitude (0 = no modulation, 1 = full swing).
**Typical uses:**
- LFO Out → VCO `cv_freq_hz` for **vibrato** (pitch wobble)
- LFO Out → Filter `cv_cutoff_hz` for a **wah/sweep** effect
- LFO Out → VCA `cv_gain` for **tremolo** (volume wobble)",
}; };
#[inline] #[inline]

View File

@@ -37,6 +37,16 @@ impl Vco {
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz", labels: &[] }, 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"] }, ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
], ],
description: "\
## VCO — Voltage-Controlled Oscillator
Generates a periodic audio-rate waveform at the set frequency.
**Waveforms:** Sine · Saw · Square · Triangle · Pulse
**Freq** sets the base pitch. Connect an LFO or keyboard CV to `cv_freq_hz` for 1 V/oct pitch modulation (0 V = 440 Hz, +1 V = 880 Hz).
**Typical chain:** VCO Out → Filter In → VCA In → Output In",
}; };
#[inline] #[inline]

View File

@@ -22,6 +22,16 @@ impl Vca {
params: &[ params: &[
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] }, ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] },
], ],
description: "\
## VCA — Voltage-Controlled Amplifier
Scales the audio level of a signal. Audio enters on **In** and exits at a controlled volume on **Out**.
**Gain** knob sets the base level (0 = silence, 1 = unity gain).
Connect an ADSR **Env** output to the `cv_gain` jack to apply an amplitude envelope — the CV value multiplies with the knob gain each sample.
**Typical chain:** VCO Out → VCA In → Output In, with ADSR Env → VCA cv_gain",
}; };
} }

View File

@@ -141,6 +141,13 @@ impl Module {
&& y >= self.y && y <= self.y + MOD_HEADER && y >= self.y && y <= self.y + MOD_HEADER
} }
/// Hit-test the `?` help badge drawn in the top-right of the header.
fn hit_help_badge(&self, x: f32, y: f32) -> bool {
let bx = self.x + MOD_W - 10.0;
let by = self.y + MOD_HEADER / 2.0;
hypot(x - bx, y - by) <= 9.0
}
fn hit_body(&self, x: f32, y: f32) -> bool { fn hit_body(&self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.x + MOD_W x >= self.x && x <= self.x + MOD_W
&& y >= self.y && y <= self.y + self.height() && y >= self.y && y <= self.y + self.height()
@@ -490,6 +497,18 @@ impl PatchBay {
} }
} }
/// Returns the markdown description for the module whose `?` badge is at
/// (x, y), or an empty string if none is hovered. JS calls this on every
/// `mousemove` over the patch bay canvas to drive the tooltip overlay.
pub fn get_tooltip_at(&self, x: f32, y: f32) -> String {
for m in self.modules.iter().rev() {
if m.hit_help_badge(x, y) {
return m.descriptor().description.to_string();
}
}
String::new()
}
/// Double-click removes the topmost module under the cursor. /// Double-click removes the topmost module under the cursor.
pub fn on_double_click(&mut self, x: f32, y: f32) { pub fn on_double_click(&mut self, x: f32, y: f32) {
if y < PALETTE_H { return; } if y < PALETTE_H { return; }
@@ -685,7 +704,25 @@ impl PatchBay {
(m.y + MOD_HEADER * 0.70) as f64, (m.y + MOD_HEADER * 0.70) as f64,
); );
// ? help badge — top-right corner of every module header
{
let bx = (m.x + MOD_W - 10.0) as f64;
let by = (m.y + MOD_HEADER / 2.0) as f64;
ctx.begin_path();
let _ = ctx.arc(bx, by, 7.0, 0.0, core::f64::consts::TAU);
ctx.set_fill_style_str("rgba(0,0,0,0.30)");
ctx.fill();
ctx.set_stroke_style_str("rgba(255,255,255,0.25)");
ctx.set_line_width(1.0);
ctx.stroke();
ctx.set_fill_style_str("rgba(255,255,255,0.70)");
ctx.set_font("bold 9px sans-serif");
ctx.set_text_align("center");
let _ = ctx.fill_text("?", bx, by + 3.5);
}
// Output node: LIVE / UNPATCHED badge on the right of the header // Output node: LIVE / UNPATCHED badge on the right of the header
// (shifted left to clear the ? badge)
if is_out { if is_out {
if has_signal { if has_signal {
ctx.set_fill_style_str("#22c55e"); ctx.set_fill_style_str("#22c55e");
@@ -693,7 +730,7 @@ impl PatchBay {
ctx.set_text_align("right"); ctx.set_text_align("right");
let _ = ctx.fill_text( let _ = ctx.fill_text(
"● LIVE", "● LIVE",
(m.x + MOD_W - 6.0) as f64, (m.x + MOD_W - 24.0) as f64,
(m.y + MOD_HEADER * 0.70) as f64, (m.y + MOD_HEADER * 0.70) as f64,
); );
} else { } else {
@@ -702,7 +739,7 @@ impl PatchBay {
ctx.set_text_align("right"); ctx.set_text_align("right");
let _ = ctx.fill_text( let _ = ctx.fill_text(
"unpatched", "unpatched",
(m.x + MOD_W - 6.0) as f64, (m.x + MOD_W - 24.0) as f64,
(m.y + MOD_HEADER * 0.70) as f64, (m.y + MOD_HEADER * 0.70) as f64,
); );
} }

86
www/bootstrap.js vendored
View File

@@ -28,6 +28,86 @@ function fitCanvas(canvas) {
} }
} }
// ── Tooltip helpers ───────────────────────────────────────────────────────────
/** Minimal markdown → HTML converter for the module description tooltips. */
function mdToHtml(md) {
const lines = md.split("\n");
const out = [];
let inUl = false;
for (const raw of lines) {
const line = raw.trimEnd();
if (line.startsWith("## ")) {
if (inUl) { out.push("</ul>"); inUl = false; }
out.push(`<h2>${esc(line.slice(3))}</h2>`);
} else if (line.startsWith("> ")) {
if (inUl) { out.push("</ul>"); inUl = false; }
out.push(`<blockquote>${inline(line.slice(2))}</blockquote>`);
} else if (line.startsWith("- ")) {
if (!inUl) { out.push("<ul>"); inUl = true; }
out.push(`<li>${inline(line.slice(2))}</li>`);
} else {
if (inUl) { out.push("</ul>"); inUl = false; }
if (line === "") {
out.push("<br>");
} else {
out.push(`<p>${inline(line)}</p>`);
}
}
}
if (inUl) out.push("</ul>");
return out.join("");
}
function esc(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function inline(s) {
// `code`, **bold**
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
}
const _tooltip = { el: null };
function getTooltipEl() {
if (!_tooltip.el) _tooltip.el = document.getElementById("tooltip");
return _tooltip.el;
}
function updateTooltip(clientX, clientY, markdown) {
const el = getTooltipEl();
if (!el) return;
if (!markdown) { hideTooltip(); return; }
el.innerHTML = mdToHtml(markdown);
el.style.display = "block";
// Position: prefer right of cursor, flip left if off-screen
const margin = 14;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = clientX + margin;
let top = clientY + margin;
el.style.left = "0";
el.style.top = "0";
const tw = el.offsetWidth;
const th = el.offsetHeight;
if (left + tw > vw - 4) left = clientX - tw - margin;
if (top + th > vh - 4) top = clientY - th - margin;
el.style.left = left + "px";
el.style.top = top + "px";
}
function hideTooltip() {
const el = getTooltipEl();
if (el) el.style.display = "none";
}
// ── Resize handle ───────────────────────────────────────────────────────────── // ── Resize handle ─────────────────────────────────────────────────────────────
function initResizeHandle() { function initResizeHandle() {
@@ -434,8 +514,12 @@ async function bootstrap() {
// ── Patch bay pointer events ────────────────────────────────────────── // ── Patch bay pointer events ──────────────────────────────────────────
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY)); pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY)); pbCanvas.addEventListener("pointermove", e => {
patchbay.on_pointer_move(e.offsetX, e.offsetY);
updateTooltip(e.clientX, e.clientY, patchbay.get_tooltip_at(e.offsetX, e.offsetY));
});
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY)); pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointerleave", () => hideTooltip());
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY)); pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
// ── Patch router: Web Audio graph driven by patch bay topology ──────── // ── Patch router: Web Audio graph driven by patch bay topology ────────

View File

@@ -151,10 +151,50 @@
z-index: 100; transition: opacity 0.4s; z-index: 100; transition: opacity 0.4s;
} }
#loader.hidden { opacity: 0; pointer-events: none; } #loader.hidden { opacity: 0; pointer-events: none; }
#tooltip {
position: fixed;
z-index: 200;
max-width: 320px;
background: #1a1e2c;
border: 1px solid #3b4560;
border-radius: 8px;
padding: 12px 14px;
font-size: 0.72rem;
line-height: 1.65;
color: #c8d0e0;
pointer-events: none;
box-shadow: 0 6px 28px rgba(0,0,0,0.75);
display: none;
}
#tooltip h2 {
font-size: 0.78rem;
color: #e8eaf6;
margin-bottom: 6px;
letter-spacing: 0.04em;
}
#tooltip strong { color: #a5d8ff; }
#tooltip code {
background: rgba(255,255,255,0.08);
border-radius: 3px;
padding: 0 3px;
font-size: 0.68rem;
color: #94e2d5;
}
#tooltip blockquote {
border-left: 2px solid #3b4560;
margin: 6px 0 0;
padding-left: 8px;
color: #8899aa;
}
#tooltip ul {
margin: 4px 0 0 14px;
}
</style> </style>
</head> </head>
<body> <body>
<div id="loader">Loading WASM module…</div> <div id="loader">Loading WASM module…</div>
<div id="tooltip"></div>
<header>Analogue Synth Visualiser</header> <header>Analogue Synth Visualiser</header>