use std::fmt::Write;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySigType {
Sharp,
Flat,
Natural,
}
const SHARP_ORDER: &[(&str, f32)] = &[
("F", 0.0), ("C", 1.5), ("G", -0.5), ("D", 1.0), ("A", 2.5), ("E", 0.5), ("B", 2.0), ];
const FLAT_ORDER: &[(&str, f32)] = &[
("B", 2.0),
("E", 0.5),
("A", 2.5),
("D", 1.0),
("G", 3.0),
("C", 1.5),
("F", 3.5),
];
#[must_use]
pub fn key_signature_for(key: &str) -> Option<(usize, KeySigType)> {
let trimmed = key.trim();
if trimmed.is_empty() {
return None;
}
let mut ascii = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
match ch {
'\u{266D}' => ascii.push('b'),
'\u{266F}' => ascii.push('#'),
'\u{00A0}' | '\u{3000}' => ascii.push(' '),
other => ascii.push(other),
}
}
let ascii = ascii.trim();
let mut chars = ascii.chars();
let root = chars.next()?.to_ascii_uppercase();
if !('A'..='G').contains(&root) {
return None;
}
let mut accidental = String::new();
let rest: String = chars.collect();
let mut rest_chars = rest.chars().peekable();
if let Some(&c) = rest_chars.peek() {
if c == 'b' || c == '#' {
accidental.push(c);
rest_chars.next();
}
}
let suffix: String = rest_chars.collect();
let suffix_trim = suffix.trim().to_ascii_lowercase();
let is_minor = matches!(suffix_trim.as_str(), "m" | "min" | "minor");
if !is_minor && !suffix_trim.is_empty() {
return None;
}
let note = format!("{root}{accidental}");
let major: &[(&str, (usize, KeySigType))] = &[
("C", (0, KeySigType::Natural)),
("G", (1, KeySigType::Sharp)),
("D", (2, KeySigType::Sharp)),
("A", (3, KeySigType::Sharp)),
("E", (4, KeySigType::Sharp)),
("B", (5, KeySigType::Sharp)),
("F#", (6, KeySigType::Sharp)),
("C#", (7, KeySigType::Sharp)),
("F", (1, KeySigType::Flat)),
("Bb", (2, KeySigType::Flat)),
("Eb", (3, KeySigType::Flat)),
("Ab", (4, KeySigType::Flat)),
("Db", (5, KeySigType::Flat)),
("Gb", (6, KeySigType::Flat)),
("Cb", (7, KeySigType::Flat)),
];
let minor: &[(&str, (usize, KeySigType))] = &[
("A", (0, KeySigType::Natural)),
("E", (1, KeySigType::Sharp)),
("B", (2, KeySigType::Sharp)),
("F#", (3, KeySigType::Sharp)),
("C#", (4, KeySigType::Sharp)),
("G#", (5, KeySigType::Sharp)),
("D#", (6, KeySigType::Sharp)),
("A#", (7, KeySigType::Sharp)),
("D", (1, KeySigType::Flat)),
("G", (2, KeySigType::Flat)),
("C", (3, KeySigType::Flat)),
("F", (4, KeySigType::Flat)),
("Bb", (5, KeySigType::Flat)),
("Eb", (6, KeySigType::Flat)),
("Ab", (7, KeySigType::Flat)),
];
let table = if is_minor { minor } else { major };
table.iter().find(|(n, _)| *n == note).map(|(_, v)| *v)
}
#[must_use]
pub fn key_signature_svg(key: &str) -> String {
let sig = key_signature_for(key);
let accidental_count = sig.map(|(c, _)| c).unwrap_or(0);
let accidental_start: f32 = 14.0;
let accidental_spacing: f32 = 4.0;
let tail_right: f32 = 3.0;
let w: f32 = if accidental_count > 0 {
let calc =
accidental_start + ((accidental_count as f32 - 1.0) * accidental_spacing) + tail_right;
if calc < 18.0 { 18.0 } else { calc }
} else {
18.0
};
let vb_top: f32 = 1.0;
let h: f32 = 20.0;
let top: f32 = 4.0;
let line_gap: f32 = 3.0;
let aria = match sig {
None => format!("Key {key}"),
Some((_, KeySigType::Natural)) => format!("Key {key} (no accidentals)"),
Some((n, KeySigType::Sharp)) => {
format!("Key {key} ({n} sharp{})", if n == 1 { "" } else { "s" })
}
Some((n, KeySigType::Flat)) => {
format!("Key {key} ({n} flat{})", if n == 1 { "" } else { "s" })
}
};
let mut s = String::with_capacity(512);
let _ = write!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {vb_top} {w} {h}\" \
width=\"{w}\" height=\"{h}\" class=\"music-glyph music-glyph--key\" \
role=\"img\" aria-label=\"{aria}\">",
vb_top = vb_top,
w = w,
h = h,
aria = chordsketch_chordpro::escape::escape_xml(&aria),
);
for i in 0..5 {
let y = top + (i as f32) * line_gap;
let _ = write!(
s,
"<line x1=\"1\" x2=\"{x2}\" y1=\"{y}\" y2=\"{y}\" \
stroke=\"currentColor\" stroke-width=\"0.6\"/>",
x2 = w - 1.0,
y = y,
);
}
s.push_str(
"<path d=\"M9 19 C 9 21, 5.5 21, 5.5 18.5 C 5.5 16, 9 16, 9 14 \
C 9 11, 4.5 9, 4.5 7 C 4.5 4, 8.5 2.5, 9.5 5 \
C 10.5 8, 6 9.5, 6 13 C 6 16, 10 16, 10 13.5\" \
fill=\"none\" stroke=\"currentColor\" stroke-width=\"1\" stroke-linecap=\"round\"/>",
);
if let Some((count, sig_type)) = sig {
if sig_type != KeySigType::Natural {
let order = if sig_type == KeySigType::Sharp {
SHARP_ORDER
} else {
FLAT_ORDER
};
for (i, &(_, y_step)) in order.iter().take(count).enumerate() {
let cx = accidental_start + (i as f32) * accidental_spacing;
let cy = top + y_step * line_gap;
if sig_type == KeySigType::Sharp {
write_sharp(&mut s, cx, cy);
} else {
write_flat(&mut s, cx, cy);
}
}
}
}
s.push_str("</svg>");
s
}
fn write_sharp(s: &mut String, cx: f32, cy: f32) {
let w = 2.2;
let h = 4.4;
let _ = write!(
s,
"<g stroke=\"currentColor\" stroke-width=\"0.55\" stroke-linecap=\"round\">\
<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x1}\" y2=\"{y2}\"/>\
<line x1=\"{x2}\" y1=\"{y3}\" x2=\"{x2}\" y2=\"{y4}\"/>\
<line x1=\"{a1}\" y1=\"{b1}\" x2=\"{a2}\" y2=\"{b2}\"/>\
<line x1=\"{a1}\" y1=\"{b3}\" x2=\"{a2}\" y2=\"{b4}\"/>\
</g>",
x1 = cx - w / 2.0,
x2 = cx + w / 2.0,
y1 = cy - h / 2.0,
y2 = cy + h / 2.0 + 0.4,
y3 = cy - h / 2.0 - 0.4,
y4 = cy + h / 2.0,
a1 = cx - w / 2.0 - 0.3,
a2 = cx + w / 2.0 + 0.3,
b1 = cy - 0.8,
b2 = cy - 1.4,
b3 = cy + 1.4,
b4 = cy + 0.8,
);
}
fn write_flat(s: &mut String, cx: f32, cy: f32) {
let _ = write!(
s,
"<g fill=\"none\" stroke=\"currentColor\" stroke-width=\"0.55\" stroke-linecap=\"round\">\
<line x1=\"{x}\" y1=\"{y1}\" x2=\"{x}\" y2=\"{y2}\"/>\
<path d=\"M {x} {p1} \
C {p2x} {p2y}, {p3x} {p3y}, {x} {p4}\"/>\
</g>",
x = cx - 0.8,
y1 = cy - 2.5,
y2 = cy + 2.2,
p1 = cy + 0.4,
p2x = cx + 0.6,
p2y = cy - 0.6,
p3x = cx + 1.4,
p3y = cy + 1.4,
p4 = cy + 2.2,
);
}
#[must_use]
pub fn metronome_svg(bpm_raw: &str) -> String {
let bpm: f32 = bpm_raw.trim().parse::<f32>().unwrap_or(60.0);
let safe_bpm = if bpm.is_finite() && bpm > 0.0 {
bpm
} else {
60.0
};
let period = (60.0 / safe_bpm).clamp(0.05, 5.0);
let aria = if bpm.is_finite() && bpm > 0.0 {
if (bpm - bpm.round()).abs() < f32::EPSILON {
format!("Metronome at {} BPM", bpm as i32)
} else {
format!("Metronome at {bpm} BPM")
}
} else {
"Metronome (BPM unknown)".to_string()
};
let mut s = String::with_capacity(512);
let _ = write!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 4 18 18\" \
width=\"18\" height=\"18\" class=\"music-glyph music-glyph--metronome\" \
style=\"--cs-metronome-period:{:.3}s\" role=\"img\" aria-label=\"{}\">\
<path d=\"M 3 21 L 15 21 L 12.5 5 L 5.5 5 Z\" fill=\"none\" \
stroke=\"currentColor\" stroke-width=\"0.9\" stroke-linejoin=\"round\"/>\
<circle cx=\"9\" cy=\"19\" r=\"0.7\" fill=\"currentColor\"/>\
<g class=\"music-glyph--metronome__pendulum\">\
<line x1=\"9\" y1=\"19\" x2=\"9\" y2=\"7\" stroke=\"currentColor\" \
stroke-width=\"0.9\" stroke-linecap=\"round\"/>\
<circle cx=\"9\" cy=\"9\" r=\"1.1\" fill=\"currentColor\"/>\
</g></svg>",
period,
chordsketch_chordpro::escape::escape_xml(&aria),
);
s
}
#[must_use]
pub fn time_signature_html(value: &str) -> String {
let trimmed = value.trim();
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 {
return format!(
"<span class=\"music-glyph music-glyph--time\">{}</span>",
chordsketch_chordpro::escape::escape_xml(trimmed),
);
}
let num = parts[0].trim();
let den = parts[1].trim();
if num.is_empty()
|| den.is_empty()
|| !num.chars().all(|c| c.is_ascii_digit())
|| !den.chars().all(|c| c.is_ascii_digit())
{
return format!(
"<span class=\"music-glyph music-glyph--time\">{}</span>",
chordsketch_chordpro::escape::escape_xml(trimmed),
);
}
let aria = format!("Time signature {num} over {den}");
format!(
"<span class=\"music-glyph music-glyph--time\" \
role=\"img\" aria-label=\"{aria}\">\
<span class=\"music-glyph--time__num\" aria-hidden=\"true\">{num}</span>\
<span class=\"music-glyph--time__bar\" aria-hidden=\"true\"></span>\
<span class=\"music-glyph--time__den\" aria-hidden=\"true\">{den}</span>\
</span>",
aria = chordsketch_chordpro::escape::escape_xml(&aria),
num = chordsketch_chordpro::escape::escape_xml(num),
den = chordsketch_chordpro::escape::escape_xml(den),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_signature_major_table() {
assert_eq!(key_signature_for("C"), Some((0, KeySigType::Natural)));
assert_eq!(key_signature_for("G"), Some((1, KeySigType::Sharp)));
assert_eq!(key_signature_for("D"), Some((2, KeySigType::Sharp)));
assert_eq!(key_signature_for("F#"), Some((6, KeySigType::Sharp)));
assert_eq!(key_signature_for("Bb"), Some((2, KeySigType::Flat)));
assert_eq!(key_signature_for("Cb"), Some((7, KeySigType::Flat)));
}
#[test]
fn key_signature_minor_table() {
assert_eq!(key_signature_for("Am"), Some((0, KeySigType::Natural)));
assert_eq!(key_signature_for("Em"), Some((1, KeySigType::Sharp)));
assert_eq!(key_signature_for("Dm"), Some((1, KeySigType::Flat)));
assert_eq!(key_signature_for("Cm"), Some((3, KeySigType::Flat)));
assert_eq!(key_signature_for("F#m"), Some((3, KeySigType::Sharp)));
}
#[test]
fn key_signature_unicode_and_spaces() {
assert_eq!(key_signature_for("F♯"), Some((6, KeySigType::Sharp)));
assert_eq!(key_signature_for("B♭"), Some((2, KeySigType::Flat)));
assert_eq!(key_signature_for("E minor"), Some((1, KeySigType::Sharp)));
assert_eq!(key_signature_for("e MIN"), Some((1, KeySigType::Sharp)));
}
#[test]
fn key_signature_unparseable_returns_none() {
assert_eq!(key_signature_for(""), None);
assert_eq!(key_signature_for("not a key"), None);
assert_eq!(key_signature_for("H"), None);
}
#[test]
fn key_signature_svg_has_clef_and_staff() {
let svg = key_signature_svg("A");
assert!(svg.starts_with("<svg "));
assert!(svg.contains("music-glyph--key"));
let line_count = svg.matches("<line").count();
assert!(
line_count >= 5,
"expected at least 5 lines, got {line_count} in {svg}"
);
assert_eq!(svg.matches("<g ").count(), 3);
}
#[test]
fn key_signature_svg_natural_emits_no_accidentals() {
let svg = key_signature_svg("C");
assert!(!svg.contains("<g "));
}
#[test]
fn metronome_svg_writes_period_from_bpm() {
let svg = metronome_svg("120");
assert!(
svg.contains("--cs-metronome-period:0.500s"),
"expected 0.500s half-cycle at 120 BPM; got: {svg}"
);
assert!(svg.contains("music-glyph--metronome__pendulum"));
}
#[test]
fn metronome_svg_period_at_60_bpm() {
let svg = metronome_svg("60");
assert!(svg.contains("--cs-metronome-period:1.000s"));
}
#[test]
fn metronome_svg_clamps_extreme_bpm() {
let svg = metronome_svg("99999");
assert!(svg.contains("--cs-metronome-period:0.050s"));
}
#[test]
fn metronome_svg_inverted_pendulum_pivot() {
let svg = metronome_svg("120");
assert!(
svg.contains("<circle cx=\"9\" cy=\"19\" r=\"0.7\""),
"expected static pivot circle at (9, 19); got: {svg}"
);
assert!(
svg.contains("<line x1=\"9\" y1=\"19\" x2=\"9\" y2=\"7\""),
"rod must extend upward from base pivot; got: {svg}"
);
}
#[test]
fn metronome_svg_fallbacks_for_non_numeric() {
let svg = metronome_svg("nonsense");
assert!(svg.contains("--cs-metronome-period:1.000s"));
}
#[test]
fn time_signature_html_stacks_digits() {
let html = time_signature_html("4/4");
assert!(html.contains("music-glyph--time__num\" aria-hidden=\"true\">4</span>"));
assert!(html.contains("music-glyph--time__bar\""));
assert!(html.contains("music-glyph--time__den\" aria-hidden=\"true\">4</span>"));
assert!(html.contains("aria-label=\"Time signature 4 over 4\""));
}
#[test]
fn time_signature_html_falls_back_for_non_fraction() {
let html = time_signature_html("C");
assert!(html.contains("music-glyph--time\">C</span>"));
assert!(!html.contains("music-glyph--time__num"));
}
#[test]
fn time_signature_html_carries_no_conductor_markup() {
let html = time_signature_html("4/4");
assert!(!html.contains("music-glyph--time--conduct-"));
assert!(!html.contains("--cs-time-period"));
}
}