pub type Rgb = [u8; 3];
#[derive(Clone, Debug, PartialEq)]
pub struct Front {
pub name: String,
pub paper: Rgb,
pub trim: Rgb,
pub inks: [Rgb; 4],
}
#[derive(Clone, Debug, PartialEq)]
pub struct Back {
pub name: String,
pub fill: Rgb,
pub weave: Rgb,
pub accent: Rgb,
pub suit: Option<char>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Felt {
pub name: String,
pub cloth: Rgb,
pub line: Rgb,
pub print: Rgb,
}
pub const SHEET_PAPER: Rgb = [255, 255, 255];
pub const SHEET_TRIM: Rgb = [199, 215, 236];
pub const SHEET_RED: Rgb = [245, 44, 78];
pub const SHEET_SLATE: Rgb = [77, 87, 102];
fn lighter(c: Rgb, k: u8) -> Rgb {
c.map(|v| v.saturating_add(k))
}
fn front(name: &str, paper: Rgb, trim: Rgb, red: Rgb, black: Rgb) -> Front {
Front { name: name.into(), paper, trim, inks: [red, red, black, black] }
}
fn back(name: &str, fill: Rgb, accent: Rgb, suit: Option<char>) -> Back {
Back { name: name.into(), fill, weave: lighter(fill, 14), accent, suit }
}
fn felt(name: &str, cloth: Rgb) -> Felt {
Felt { name: name.into(), cloth, line: lighter(cloth, 32), print: lighter(cloth, 74) }
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Look {
pub front: usize,
pub back: usize,
pub felt: usize,
pub click: usize,
pub toggle: usize,
pub music: usize,
}
impl Look {
pub fn get(&self, kind: usize) -> usize {
[self.front, self.back, self.felt, self.click, self.toggle, self.music][kind]
}
pub fn get_mut(&mut self, kind: usize) -> &mut usize {
match kind {
0 => &mut self.front,
1 => &mut self.back,
2 => &mut self.felt,
3 => &mut self.click,
4 => &mut self.toggle,
_ => &mut self.music,
}
}
}
pub const KINDS: [&str; 6] = ["FRONT", "BACK", "FELT", "CLICK", "TOGGLE", "MUSIC"];
pub const CLICKS: [&str; 3] = ["DIE", "CHIP", "CLACK"];
pub const TOGGLES: [&str; 3] = ["FAN", "SHOVE", "SWISH"];
pub const MUSIC: [&str; 2] = ["ON", "OFF"];
#[derive(Clone, Debug, PartialEq)]
pub struct Themes {
pub fronts: Vec<Front>,
pub backs: Vec<Back>,
pub felts: Vec<Felt>,
}
impl Default for Themes {
fn default() -> Self {
let mut four = front("FOUR COLOUR", SHEET_PAPER, SHEET_TRIM, SHEET_RED, SHEET_SLATE);
four.inks[1] = [40, 104, 214];
four.inks[2] = [34, 140, 64];
Themes {
fronts: vec![
front("CLASSIC", SHEET_PAPER, SHEET_TRIM, SHEET_RED, SHEET_SLATE),
four,
front("IVORY", [246, 238, 218], [222, 206, 172], [190, 38, 52], [40, 40, 52]),
],
backs: vec![
back("PLUM", [50, 28, 66], [222, 178, 92], Some('S')),
back("CRIMSON", [84, 18, 30], [236, 214, 170], Some('H')),
back("MIDNIGHT", [20, 28, 58], [196, 208, 228], Some('D')),
back("CHARCOAL", [30, 28, 36], [222, 178, 92], Some('C')),
back("CLASSIC", [0; 3], [0; 3], None),
],
felts: vec![
Felt { line: [72, 142, 108], print: [115, 184, 148], ..felt("GREEN", [38, 110, 80]) },
felt("BLUE", [42, 86, 124]),
felt("WINE", [122, 50, 60]),
felt("VIOLET", [86, 62, 116]),
felt("SLATE", [72, 80, 90]),
],
}
}
}
impl Themes {
pub fn count(&self, kind: usize) -> usize {
[self.fronts.len(), self.backs.len(), self.felts.len(), CLICKS.len(), TOGGLES.len(), MUSIC.len()][kind]
}
pub fn name(&self, kind: usize, i: usize) -> &str {
match kind {
0 => &self.fronts[i].name,
1 => &self.backs[i].name,
2 => &self.felts[i].name,
3 => CLICKS[i],
4 => TOGGLES[i],
_ => MUSIC[i],
}
}
pub fn default_look(&self) -> Look {
let mut look = Look::default();
for (kind, name) in [(0, "IVORY"), (1, "CHARCOAL")] {
*look.get_mut(kind) = self.find(kind, name).unwrap_or(0);
}
look
}
pub fn find(&self, kind: usize, name: &str) -> Option<usize> {
(0..self.count(kind)).find(|&i| self.name(kind, i) == name)
}
pub fn add_custom(&mut self, text: &str) -> Vec<String> {
let mut errors = Vec::new();
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Err(e) = self.add_line(line) {
errors.push(format!("themes.txt line {}: {e}", n + 1));
}
}
errors
}
fn add_line(&mut self, line: &str) -> Result<(), String> {
let mut words = line.split_whitespace();
let kind = words.next().unwrap_or_default();
let name = words.next().ok_or("no name")?.replace('_', " ").to_uppercase();
let mut keys = Vec::new();
for w in words {
let (k, v) = w.split_once('=').ok_or_else(|| format!("expected key=value, got {w:?}"))?;
keys.push((k, v));
}
let rgb = |key: &str| -> Result<Option<Rgb>, String> {
let Some((_, v)) = keys.iter().find(|(k, _)| *k == key) else { return Ok(None) };
let parts: Vec<u8> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
match parts[..] {
[r, g, b] if v.split(',').count() == 3 => Ok(Some([r, g, b])),
_ => Err(format!("{key}={v} is not R,G,B (each 0-255)")),
}
};
let known: &[&str] = match kind {
"front" => &["paper", "trim", "red", "black", "hearts", "diamonds", "clubs", "spades"],
"back" => &["fill", "weave", "accent", "suit"],
"felt" => &["cloth", "line", "print"],
_ => return Err(format!("expected front, back or felt, got {kind:?}")),
};
if let Some((k, _)) = keys.iter().find(|(k, _)| !known.contains(k)) {
return Err(format!("{kind} has no {k:?}"));
}
match kind {
"front" => {
let base = &self.fronts[0];
let mut f = Front { name, paper: base.paper, trim: base.trim, inks: base.inks };
f.paper = rgb("paper")?.unwrap_or(f.paper);
f.trim = rgb("trim")?.unwrap_or(f.trim);
if let Some(c) = rgb("red")? {
f.inks[0] = c;
f.inks[1] = c;
}
if let Some(c) = rgb("black")? {
f.inks[2] = c;
f.inks[3] = c;
}
for (i, suit) in ["hearts", "diamonds", "clubs", "spades"].iter().enumerate() {
f.inks[i] = rgb(suit)?.unwrap_or(f.inks[i]);
}
upsert(&mut self.fronts, f, |f| &f.name);
}
"back" => {
let base = &self.backs[0];
let fill = rgb("fill")?.unwrap_or(base.fill);
let suit = match keys.iter().find(|(k, _)| *k == "suit").map(|(_, v)| *v) {
None => base.suit,
Some(s @ ("S" | "H" | "D" | "C")) => s.chars().next(),
Some(s) => return Err(format!("suit={s} is not S, H, D or C")),
};
let b = Back {
name,
fill,
weave: rgb("weave")?.unwrap_or(lighter(fill, 14)),
accent: rgb("accent")?.unwrap_or(base.accent),
suit,
};
upsert(&mut self.backs, b, |b| &b.name);
}
_ => {
let cloth = rgb("cloth")?.unwrap_or(self.felts[0].cloth);
let plain = felt(&name, cloth);
let f = Felt {
line: rgb("line")?.unwrap_or(plain.line),
print: rgb("print")?.unwrap_or(plain.print),
..plain
};
upsert(&mut self.felts, f, |f| &f.name);
}
}
Ok(())
}
}
fn upsert<T>(v: &mut Vec<T>, item: T, name: impl Fn(&T) -> &String) {
match v.iter().position(|x| name(x) == name(&item)) {
Some(i) => v[i] = item,
None => v.push(item),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_looks_are_added_with_defaults() {
let mut t = Themes::default();
let errors = t.add_custom(
"# comment\n\
front sepia_tone paper=240,228,200 black=60,40,30\n\
back OCEAN fill=20,60,90 suit=D\n\
felt TEAL cloth=20,96,104 print=1,2,3\n",
);
assert!(errors.is_empty(), "{errors:?}");
let f = t.fronts.last().unwrap();
assert_eq!(f.name, "SEPIA TONE");
assert_eq!(f.paper, [240, 228, 200]);
assert_eq!(f.trim, SHEET_TRIM);
assert_eq!(f.inks, [SHEET_RED, SHEET_RED, [60, 40, 30], [60, 40, 30]]);
let b = t.backs.last().unwrap();
assert_eq!((b.weave, b.suit), ([34, 74, 104], Some('D')));
let felt = t.felts.last().unwrap();
assert_eq!((felt.line, felt.print), ([52, 128, 136], [1, 2, 3]));
}
#[test]
fn the_readme_example_reads() {
let readme = include_str!("../README.md");
let example = readme.split("```text\n").nth(1).unwrap().split("```").next().unwrap();
let mut t = Themes::default();
assert!(t.add_custom(example).is_empty());
assert_eq!(t.fronts.last().unwrap().name, "SEPIA");
assert_eq!(t.backs.last().unwrap().name, "OCEAN");
assert_eq!(t.felts.last().unwrap().name, "TEAL");
}
#[test]
fn same_name_replaces() {
let mut t = Themes::default();
let n = t.felts.len();
assert!(t.add_custom("felt green cloth=0,80,0").is_empty());
assert_eq!(t.felts.len(), n);
assert_eq!(t.felts[0].cloth, [0, 80, 0]);
}
#[test]
fn bad_lines_are_reported_and_skipped() {
let mut t = Themes::default();
let before = t.clone();
let errors = t.add_custom(
"table X\nfelt\nfelt A cloth=1,2\nfelt B cloth=1,2,300\nfelt C colour=1,2,3\nback D suit=X\nfelt E cloth\n",
);
assert_eq!(errors.len(), 7, "{errors:?}");
assert!(errors[0].starts_with("themes.txt line 1:"));
assert_eq!(t, before);
}
}