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:
parent
0417ef89cc
commit
00c1f1c3c9
|
|
@ -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) => {
|
||||||
|
|
|
||||||
201
hh/src/theme.rs
201
hh/src/theme.rs
|
|
@ -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 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)]
|
#[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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
hh/src/ui.rs
10
hh/src/ui.rs
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user