// 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"
}