// Glyphs drawn on a 24x24 grid. Bold, like the other FeloDeck plugins: a body is always filled // (details are cut out of it), round 2.4px strokes only for lines like arrows and waves. // Used for the static icons (`cargo xtask icons`), the settings pages and keys drawn at runtime. use std::collections::HashMap; use std::sync::LazyLock; use crate::glyph_fit::FIT; use crate::js::{fmt, round}; pub mod colors { pub const WHITE: &str = "#FFFFFF"; pub const GRAY: &str = "#6B6B6B"; pub const MUTED: &str = "#9A9AA2"; pub const RED: &str = "#E5484D"; pub const GREEN: &str = "#30A46C"; pub const BLUE: &str = "#3E8EF7"; pub const AMBER: &str = "#F5A524"; } pub static COLORS: &[(&str, &str)] = &[ ("white", colors::WHITE), ("gray", colors::GRAY), ("muted", colors::MUTED), ("red", colors::RED), ("green", colors::GREEN), ("blue", colors::BLUE), ("amber", colors::AMBER), ]; fn gear(cx: f64, cy: f64, outer: f64, inner: f64, teeth: usize) -> String { let mut points = Vec::new(); let step = (std::f64::consts::PI * 2.0) / (teeth * 4) as f64; for i in 0..teeth * 4 { let r = if i % 4 < 2 { outer } else { inner }; let a = i as f64 * step - std::f64::consts::PI / 2.0 + step / 2.0; points.push(format!("{} {}", crate::js::to_fixed(cx + r * a.cos(), 2), crate::js::to_fixed(cy + r * a.sin(), 2))); } format!("M{}Z", points.join("L")) } const FILL: &str = r#"fill="currentColor""#; // A filled body with details cut out of it. Cuts are lines in the stroke width, or shapes with // fill="#000"; add puts parts back on top of a cut. fn cut(id: &str, body: &str, cuts: &str, add: &str) -> String { format!( r##"{body}{cuts}{add}"## ) } fn build() -> Vec<(&'static str, String)> { let fill = FILL; let speaker = format!(r#""#); let s = |text: &str| text.to_string(); vec![ ( "plugin", cut( "plugin", &format!(r#""#, gear(12.0, 12.0, 10.6, 8.2, 8)), r##""##, "", ), ), ( "scene", format!( r#""# ), ), ( "animation", format!(r#""#), ), ( "studio", format!( r#""# ), ), ( "video", cut( "video", r#""#, r##""##, "", ), ), ( "videoStop", cut( "videoStop", r#""#, r##""##, "", ), ), ("volume", format!(r#"{speaker}"#)), ("volumeUp", format!(r#"{speaker}"#)), ("volumeDown", format!(r#"{speaker}"#)), ("volumeSet", format!(r#"{speaker}"#)), ("mute", format!(r#"{speaker}"#)), ("unmuted", format!(r#"{speaker}"#)), ( "monitor", format!( r#""# ), ), ( "dropped", cut( "dropped", r#""#, r#""#, "", ), ), ( "cpu", cut( "cpu", r#""#, r#""#, "", ) + r#""#, ), ( "previous", format!(r#""#), ), ( "next", format!(r#""#), ), ("back", format!(r#""#)), ( "replay", format!( r#""# ), ), ( "browser", cut( "browser", r#""#, r#""#, "", ), ), ("record", format!(r#""#)), ("pause", cut("pause", r#""#, r#""#, "")), ( "stream", format!( r#""# ), ), ( "transition", s( r#""#, ), ), ("filter", format!(r#""#)), ( "virtualcam", format!( r#""# ), ), ( "window", cut( "window", r#""#, r##""##, "", ), ), ( "dial", cut("dial", r#""#, r#""#, "") + r#""#, ), ("power", s(r#""#)), ("lock", format!(r#""#)), ( "twitch", cut( "twitch", r##""##, r#""#, "", ), ), ( "eye", cut( "eye", r##""##, r#""#, "", ), ), ( "eyeOff", cut( "eyeOff", r##""##, r#""#, r#""#, ), ), ( "keyboard", cut( "keyboard", r#""#, r#""#, "", ), ), ( "camera", cut( "camera", r##""##, r#""#, "", ), ), ("chapter", format!(r#""#)), ("split", s(r#""#)), ("play", format!(r#""#)), ( "pauseMedia", format!( r#""# ), ), ( "playPause", format!( r#""# ), ), ("stop", format!(r#""#)), ("restart", s(r#""#)), ( "prevTrack", format!(r#""#), ), ( "nextTrack", format!(r#""#), ), ("forward", format!(r#""#)), ("rewind", format!(r#""#)), ( "profile", format!(r#""#), ), ( "collection", format!( r#""# ), ), ( "image", cut( "image", r#""#, r##""##, "", ), ), ("text", s(r#""#)), ( "health", cut( "health", r##""##, r##""##, "", ), ), ] } /// Every glyph by name, in the order they are written above. pub static GLYPHS: LazyLock> = LazyLock::new(build); static INDEX: LazyLock> = LazyLock::new(|| GLYPHS.iter().enumerate().map(|(i, (name, _))| (*name, i)).collect()); pub fn glyph_body(name: &str) -> Option<&'static str> { INDEX.get(name).map(|&i| GLYPHS[i].1.as_str()) } pub fn escape_xml(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '<' => out.push_str("<"), '>' => out.push_str(">"), '&' => out.push_str("&"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } /// The glyph of a volume key for each mode. Mute shows the plain speaker. pub fn volume_glyph(mode: &str) -> Option<&'static str> { VOLUME_GLYPHS.iter().find(|(m, _)| *m == mode).map(|(_, g)| *g) } pub static VOLUME_GLYPHS: &[(&str, &str)] = &[("up", "volumeUp"), ("down", "volumeDown"), ("set", "volumeSet")]; /// The glyph of a media key for each operation. Play / pause shows pause while it plays. pub fn media_glyph(op: &str) -> Option<&'static str> { MEDIA_GLYPHS.iter().find(|(m, _)| *m == op).map(|(_, g)| *g) } pub static MEDIA_GLYPHS: &[(&str, &str)] = &[ ("play", "play"), ("pause", "pauseMedia"), ("stop", "stop"), ("restart", "restart"), ("next", "nextTrack"), ("previous", "prevTrack"), ("forward", "forward"), ("back", "rewind"), ]; /// Center and size of a glyph as measured, in grid units. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Fit { pub cx: f64, pub cy: f64, pub w: f64, pub h: f64, } pub fn fit_of(name: &str) -> Option { FIT.iter().find(|(n, _)| *n == name).map(|(_, f)| *f) } // How much of the 24 grid a glyph may use: wide ones up to MAX, compact ones less, // so a circle does not look bigger than a wide eye. const MAX: f64 = 20.5; const AREA: f64 = 17.5; fn fit_scale(b: &Fit) -> f64 { crate::js::min(MAX / crate::js::max(b.w, b.h), AREA / (b.w * b.h).sqrt()) } /// How tall a glyph is drawn, in units of the 24 grid. pub fn glyph_height(name: &str) -> f64 { fit_of(name).map_or(24.0, |b| b.h * fit_scale(&b)) } fn n3(v: f64) -> f64 { round(v * 1000.0) / 1000.0 } // The fitted box of a glyph: centered on (12, 12) and with an even optical size. fn fit_transform(name: &str) -> (String, f64) { let Some(b) = fit_of(name) else { return (String::new(), 1.0) }; let k = fit_scale(&b); (format!("translate(12 12) scale({}) translate({} {})", fmt(n3(k)), fmt(n3(-b.cx)), fmt(n3(-b.cy))), k) } pub struct GlyphOpts<'a> { pub x: f64, pub y: f64, pub size: f64, pub color: &'a str, pub stroke: f64, /// false draws it as written, used to measure it. pub fit: bool, } impl Default for GlyphOpts<'_> { fn default() -> Self { GlyphOpts { x: 0.0, y: 0.0, size: 24.0, color: colors::WHITE, stroke: 2.4, fit: true } } } /// A glyph placed at (x, y) with the given size in pixels. pub fn glyph(name: &str, o: &GlyphOpts) -> String { let body = glyph_body(name).or_else(|| glyph_body("plugin")).unwrap_or(""); let (inner, scale) = if o.fit { fit_transform(name) } else { (String::new(), 1.0) }; // Keep the stroke the same on screen, whatever the glyph was scaled by. let width = n3(o.stroke / scale); let color = o.color; let outer = format!( r#""#, fmt(n3(o.x)), fmt(n3(o.y)), fmt(n3(o.size / 24.0)), fmt(width) ); if inner.is_empty() { format!("{outer}{body}") } else { format!(r#"{outer}{body}"#) } } /// Icon for the action list: white glyph, no background. pub fn action_icon_svg(name: &str, view_box: &str) -> String { format!( r#"{}"#, glyph(name, &GlyphOpts::default()) ) + "\n" }