From 00c1f1c3c90a8c4fe33a6d5decf2a0d44786b7c3 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 2 Jun 2026 17:39:37 -0700 Subject: [PATCH] feat(theme): add theme randomizer and save-to-disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.toml (via `/theme save [name]`), so a roll you like can be re-donned later with `/theme `. 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 --- hh/src/app.rs | 30 +++++++- hh/src/theme.rs | 201 +++++++++++++++++++++++++++++++++++++++++++++++- hh/src/ui.rs | 10 ++- 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/hh/src/app.rs b/hh/src/app.rs index 265e4fd..000bb2e 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -625,6 +625,17 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } else { 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) && matches!(k.code, KeyCode::Char('r')) && !app.connected @@ -1007,9 +1018,26 @@ fn handle_command( let name = rest.trim(); if name.is_empty() { app.sys(format!( - "vestments: {} — /theme ", + "vestments: {} · random · save [name] — /theme ", 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/.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 { match Theme::by_name(name) { Ok(t) => { diff --git a/hh/src/theme.rs b/hh/src/theme.rs index f2ed4f8..cb2acae 100644 --- a/hh/src/theme.rs +++ b/hh/src/theme.rs @@ -4,13 +4,13 @@ use anyhow::Context; use ratatui::style::Color; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Where the bundled `*.toml` vestments live, so `/theme ` can resolve a /// 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"); -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Theme { pub name: String, @@ -70,6 +70,47 @@ impl Theme { 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. pub fn available() -> Vec { let mut names: Vec = std::fs::read_dir(THEMES_DIR) @@ -86,6 +127,116 @@ impl Theme { names.sort(); names } + + /// Persist this vestment to `themes/.toml` so it survives the session + /// and can be re-donned later with `/theme `. 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 { + 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/.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 — `-` 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 0–360, s/v in 0–1) 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)] @@ -115,4 +266,50 @@ roster_width = 24 // missing fields fall back to the church default assert_eq!(t.system, Theme::default().system); } + + #[test] + fn random_yields_a_usable_vestment() { + let t = Theme::random(); + // Name is `-` 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 ` 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)); + } } diff --git a/hh/src/ui.rs b/hh/src/ui.rs index f560990..3dad3e3 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -146,7 +146,7 @@ fn help_lines(theme: &Theme) -> Vec> { // List whatever vestments are actually installed, so new themes show up here // automatically (church · neon · crypt · blush · matrix · wraith · …). let theme_help = format!( - "change vestments live: {}", + "vestments: {} · random · save [name]", Theme::available().join(" · ") ); vec![ @@ -209,6 +209,14 @@ fn help_lines(theme: &Theme) -> Vec> { "Ctrl-R (when closed)", "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"), Line::from(""), head("ROSTER GLYPHS"),