feat(theme): add theme randomizer and save-to-disk

Theme::random() conjures a fresh procedural vestment — a coherent HSV
palette (dark tinted surface, one bright accent, legible ink), a random
sigil, and a generated arcane name. Bound to Ctrl+Alt+P and `/theme random`.

Theme::save() persists the vestment you're wearing to themes/<slug>.toml
(via `/theme save [name]`), so a roll you like can be re-donned later with
`/theme <name>`. Theme now derives Serialize and slugify() sanitizes the
filename. Help text and the /theme usage line advertise both verbs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-06-02 17:39:37 -07:00
parent 0417ef89cc
commit 00c1f1c3c9
3 changed files with 237 additions and 4 deletions

View File

@ -625,6 +625,17 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
} else { } else {
app.sys("kill switch is for the sandbox owner (you don't hold the PTY)"); app.sys("kill switch is for the sandbox owner (you don't hold the PTY)");
} }
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& k.modifiers.contains(KeyModifiers::ALT)
&& matches!(k.code, KeyCode::Char('p'))
{
// Conjure a brand-new procedural vestment (not a bundled
// preset): fresh palette + sigil, rolled from the clock.
theme = Theme::random();
app.sys(format!(
"{} conjured vestment '{}'",
theme.sigil, theme.name
));
} else if k.modifiers.contains(KeyModifiers::CONTROL) } else if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('r')) && matches!(k.code, KeyCode::Char('r'))
&& !app.connected && !app.connected
@ -1007,9 +1018,26 @@ fn handle_command(
let name = rest.trim(); let name = rest.trim();
if name.is_empty() { if name.is_empty() {
app.sys(format!( app.sys(format!(
"vestments: {} — /theme <name>", "vestments: {} · random · save [name] — /theme <name>",
Theme::available().join(" · ") Theme::available().join(" · ")
)); ));
} else if name == "random" {
// Same as Ctrl+Alt+P — roll a fresh procedural vestment.
*theme = Theme::random();
app.sys(format!("{} conjured vestment '{}'", theme.sigil, theme.name));
} else if name == "save" || name.starts_with("save ") {
// Persist the vestment you're currently wearing (e.g. a `random`
// roll you like) to themes/<slug>.toml so it sticks around. Bare
// `/theme save` reuses the theme's own generated name.
let want = name[4..].trim();
let want = if want.is_empty() { theme.name.clone() } else { want.to_string() };
match theme.save(&want) {
Ok(slug) => app.sys(format!(
"{} saved vestment '{slug}' — re-don it anytime with /theme {slug}",
theme.sigil
)),
Err(e) => app.err(format!("couldn't save vestment: {e}")),
}
} else { } else {
match Theme::by_name(name) { match Theme::by_name(name) {
Ok(t) => { Ok(t) => {

View File

@ -4,13 +4,13 @@
use anyhow::Context; use anyhow::Context;
use ratatui::style::Color; use ratatui::style::Color;
use serde::Deserialize; use serde::{Deserialize, Serialize};
/// Where the bundled `*.toml` vestments live, so `/theme <name>` can resolve a /// Where the bundled `*.toml` vestments live, so `/theme <name>` can resolve a
/// bare name to a file at runtime (mirrors sbx.rs's script path const). /// bare name to a file at runtime (mirrors sbx.rs's script path const).
pub const THEMES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/themes"); pub const THEMES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/themes");
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct Theme { pub struct Theme {
pub name: String, pub name: String,
@ -70,6 +70,47 @@ impl Theme {
Self::load(&path).with_context(|| format!("theme '{name}' ({path})")) Self::load(&path).with_context(|| format!("theme '{name}' ({path})"))
} }
/// Conjure a brand-new vestment from scratch — not one of the bundled
/// presets. Rolls a coherent palette in HSV (dark tinted surface, one bright
/// saturated accent, light legible ink), a random occult sigil, and a
/// generated arcane name. Bound to Ctrl+Alt+P / `/theme random`.
pub fn random() -> Theme {
let mut r = Rng::seeded();
let base = r.range(0.0, 360.0); // base hue for the surface/ink family
// Accent sits at an analogous or complementary offset for contrast.
let accent_hue = (base + *r.pick(&[30.0, 150.0, 180.0, 210.0, 330.0_f32])) % 360.0;
let bg = hsv(base, r.range(0.22, 0.5), r.range(0.06, 0.12)); // deep tinted slate
let border = hsv(base, r.range(0.18, 0.42), r.range(0.40, 0.55));
let accent = hsv(accent_hue, r.range(0.70, 1.0), r.range(0.85, 1.0));
// Title is either the accent or a near-white tint of the base hue.
let title = if r.f32() < 0.5 {
accent
} else {
hsv(base, r.range(0.0, 0.12), 1.0)
};
let me = hsv(accent_hue, r.range(0.0, 0.22), r.range(0.92, 1.0)); // your ink: bright
let other = hsv(base, r.range(0.14, 0.38), r.range(0.74, 0.90));
let system = hsv(base, r.range(0.20, 0.45), r.range(0.58, 0.74));
let dim = hsv(base, r.range(0.14, 0.34), r.range(0.50, 0.66));
Theme {
name: format!("{}-{}", r.pick(&NAME_ADJ), r.pick(&NAME_NOUN)),
border,
title,
accent,
dim,
me,
other,
system,
input: me,
roster_me: accent,
bg,
roster_width: 22,
sigil: r.pick(&SIGILS).to_string(),
}
}
/// Names of the bundled vestments, sorted, for `/theme` with no argument. /// Names of the bundled vestments, sorted, for `/theme` with no argument.
pub fn available() -> Vec<String> { pub fn available() -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(THEMES_DIR) let mut names: Vec<String> = std::fs::read_dir(THEMES_DIR)
@ -86,6 +127,116 @@ impl Theme {
names.sort(); names.sort();
names names
} }
/// Persist this vestment to `themes/<name>.toml` so it survives the session
/// and can be re-donned later with `/theme <name>`. Used to keep a roll from
/// `random()` you want to wear again. Renames the theme to the saved slug so
/// the file, the `name` field, and the `/theme` argument all agree.
pub fn save(&self, name: &str) -> anyhow::Result<String> {
let slug = slugify(name);
anyhow::ensure!(!slug.is_empty(), "give the vestment a name (letters/digits)");
let mut t = self.clone();
t.name = slug.clone();
let path = format!("{THEMES_DIR}/{slug}.toml");
let body = toml::to_string_pretty(&t)
.with_context(|| format!("serialize vestment '{slug}'"))?;
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
Ok(slug)
}
}
/// Reduce a free-form name to a safe lowercase `themes/<slug>.toml` filename:
/// keep ascii letters/digits, fold any run of other chars to a single '-'.
fn slugify(name: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in name.trim().chars() {
if c.is_ascii_alphanumeric() {
if dash && !out.is_empty() {
out.push('-');
}
out.extend(c.to_lowercase());
dash = false;
} else {
dash = true;
}
}
out
}
/// Occult glyphs the randomizer can stamp as the title sigil.
const SIGILS: [&str; 12] = [
"", "", "", "", "", "", "", "", "", "", "", "",
];
/// Arcane name fragments — `<adj>-<noun>` makes a memorable vestment name.
const NAME_ADJ: [&str; 16] = [
"ashen", "umbral", "votive", "hollow", "gilded", "wraith", "septic", "occult",
"molten", "veiled", "sallow", "rotting", "sacred", "cinder", "obsidian", "vesper",
];
const NAME_NOUN: [&str; 16] = [
"reliquary", "ossuary", "vestment", "censer", "shroud", "chancel", "crypt", "sepulcher",
"litany", "chalice", "rood", "narthex", "thurible", "psalter", "ossein", "vigil",
];
/// Convert HSV (h in degrees 0360, s/v in 01) to an 8-bit-per-channel RGB
/// `Color`. Lets `random()` reason in a perceptual-ish space (pick a hue, then
/// dial saturation/value) instead of fumbling raw RGB.
fn hsv(h: f32, s: f32, v: f32) -> Color {
let h = h.rem_euclid(360.0);
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = match h as u32 / 60 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let to = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8;
Color::Rgb(to(r), to(g), to(b))
}
/// Tiny seeded xorshift64* PRNG — enough randomness to roll a fresh palette
/// without pulling in the `rand` crate. Seeded from the wall clock so every
/// `Ctrl+Alt+P` conjures a different vestment.
struct Rng(u64);
impl Rng {
fn seeded() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9e37_79b9_7f4a_7c15);
// Avoid the all-zero state, which xorshift can't escape.
Rng(nanos | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
/// Uniform f32 in [0, 1).
fn f32(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
/// Uniform f32 in [lo, hi).
fn range(&mut self, lo: f32, hi: f32) -> f32 {
lo + self.f32() * (hi - lo)
}
/// Borrow a random element from a slice.
fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
&items[(self.next_u64() % items.len() as u64) as usize]
}
} }
#[cfg(test)] #[cfg(test)]
@ -115,4 +266,50 @@ roster_width = 24
// missing fields fall back to the church default // missing fields fall back to the church default
assert_eq!(t.system, Theme::default().system); assert_eq!(t.system, Theme::default().system);
} }
#[test]
fn random_yields_a_usable_vestment() {
let t = Theme::random();
// Name is `<adj>-<noun>` and the sigil is one of the occult glyphs.
assert!(t.name.contains('-'), "name should be adj-noun: {}", t.name);
assert!(SIGILS.contains(&t.sigil.as_str()), "sigil from set: {}", t.sigil);
assert_eq!(t.roster_width, 22);
// Surface must stay dark enough to read light ink against it.
if let Color::Rgb(r, g, b) = t.bg {
let sum = r as u16 + g as u16 + b as u16;
assert!(sum < 200, "bg too bright: {r},{g},{b}");
} else {
panic!("random bg should be Rgb");
}
}
#[test]
fn random_theme_serializes_and_reloads() {
// A saved roll must reload byte-for-identical, so `/theme save` →
// `/theme <name>` gives you back the exact vestment.
let t = Theme::random();
let toml = toml::to_string_pretty(&t).expect("serialize");
let back: Theme = toml::from_str(&toml).expect("reload");
assert_eq!(back.border, t.border);
assert_eq!(back.accent, t.accent);
assert_eq!(back.bg, t.bg);
assert_eq!(back.sigil, t.sigil);
assert_eq!(back.roster_width, t.roster_width);
}
#[test]
fn slugify_makes_safe_names() {
assert_eq!(slugify(" Cool Theme!! "), "cool-theme");
assert_eq!(slugify("ashen/crypt"), "ashen-crypt");
assert_eq!(slugify("votive-litany"), "votive-litany");
assert_eq!(slugify("!!!"), "");
}
#[test]
fn hsv_primaries_round_trip() {
assert_eq!(hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
assert_eq!(hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
assert_eq!(hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
assert_eq!(hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
}
} }

View File

@ -146,7 +146,7 @@ fn help_lines(theme: &Theme) -> Vec<Line<'static>> {
// List whatever vestments are actually installed, so new themes show up here // List whatever vestments are actually installed, so new themes show up here
// automatically (church · neon · crypt · blush · matrix · wraith · …). // automatically (church · neon · crypt · blush · matrix · wraith · …).
let theme_help = format!( let theme_help = format!(
"change vestments live: {}", "vestments: {} · random · save [name]",
Theme::available().join(" · ") Theme::available().join(" · ")
); );
vec![ vec![
@ -209,6 +209,14 @@ fn help_lines(theme: &Theme) -> Vec<Line<'static>> {
"Ctrl-R (when closed)", "Ctrl-R (when closed)",
"reconnect to the house after a drop / AFK", "reconnect to the house after a drop / AFK",
), ),
kv(
"Ctrl+Alt+P · /theme random",
"conjure a random vestment (new palette + sigil)",
),
kv(
"/theme save [name]",
"keep the vestment you're wearing for reuse",
),
kv("Ctrl-C · Ctrl-Q", "quit hack-house"), kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
Line::from(""), Line::from(""),
head("ROSTER GLYPHS"), head("ROSTER GLYPHS"),