diff --git a/CLAUDE.md b/CLAUDE.md index 6887716..80a9536 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ src/bin/rmatrix/ the binary — a thin wrapper, and kept thin cli.rs flags, Settings, validate() meter.rs the stats readout behind `f` screensaver.rs what input ends --screensaver, and the startup grace + palette.rs asking the terminal for its colours (--color terminal) term.rs raw mode, alternate screen, teardown, the frame Sink bench.rs timing instrumentation behind --bench tests/ integration tests that drive the real byte stream diff --git a/Cargo.lock b/Cargo.lock index 1bd5795..44992b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "clap", "crossterm", "rand", + "rustix", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index f3b2eaf..97f3934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,13 @@ anyhow = "1.0.104" clap = { version = "4.6.6", features = ["derive"] } crossterm = "0.29.0" rand = "0.10.2" +# For one call: putting our own handle on /dev/tty into non-blocking mode, so +# `--color terminal` can wait for the palette reply without poll(2), which +# macOS does not support on terminals (see src/bin/rmatrix/palette.rs). std +# can only set the flag by hard-coding a per-OS constant. crossterm already +# depends on this exact crate and no features are added, so it brings nothing +# new into the build. +rustix = "1.1.4" thiserror = "2.0.19" # "No unwrap or expect outside tests" was a rule enforced by review alone. This diff --git a/README.md b/README.md index e7cb39e..91fcce6 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ rmatrix targets what terminals can actually do now: | Redraw | full screen | damage-tracked; an unchanged frame emits zero bytes | | Under load | blocks the animation | writes are off the simulation thread; a slow terminal costs frames, never pacing | | Terminal layer | ncurses | crossterm — no system library to install | -| Colour | 8 ANSI colours | any `#RRGGBB`, with automatic truecolor/256/16 fallback | +| Colour | 8 ANSI colours | any `#RRGGBB`, rainbow, or your terminal's own palette, with automatic truecolor/256/16 fallback | | Reproducibility | — | `--seed` replays an identical animation | ## Install @@ -89,7 +89,8 @@ cargo build --release && cp target/release/rmatrix ~/.local/bin/ ``` rmatrix [OPTIONS] - -C, --color Colour name, #RRGGBB, or "rainbow" [default: green] + -C, --color Colour name, #RRGGBB, "rainbow", or + "terminal" [default: green] -c, --charset classic | katakana | ascii | alnum | binary | hex | greek | symbols | custom [default: classic] --custom Glyphs to use with `--charset custom` @@ -121,11 +122,17 @@ rmatrix --tail-max 40 -d 0.75 rmatrix -C '#00ff41' --tail-max 40 -d 0.8 # dense, long, film-green rmatrix -c binary -C cyan # ones and zeroes +rmatrix -C terminal # your terminal's own colours, one per column rmatrix -S 0.4 # slow motion; -S 0.05 is a crawl rmatrix -s # screensaver, exits on a key or the mouse rmatrix --seed 1337 # same rain every time ``` +`--color terminal` asks the terminal for its palette (OSC 4) and gives each +column one of its six chromatic colours. A terminal that does not answer gets VS +Code's defaults instead, so the colours may not match your theme; the fade works +either way. + Quality sizes itself to your terminal by default, including across resizes — see [recommended settings](#recommended-settings). Density and tail length are left to you, since they are taste rather than cost. diff --git a/src/bin/rmatrix/cli.rs b/src/bin/rmatrix/cli.rs index 1f269fc..858a7a3 100644 --- a/src/bin/rmatrix/cli.rs +++ b/src/bin/rmatrix/cli.rs @@ -18,7 +18,7 @@ use std::time::Duration; after_help = "KEYS:\n q, Esc, Ctrl-C quit\n space pause\n 1-9 speed (3 = normal, 1 = 1/3x, 9 = 3x)\n r toggle rainbow\n c cycle charset\n b toggle bold\n f toggle stats overlay" )] pub(crate) struct Args { - /// Colour name, #RRGGBB, or "rainbow" + /// Colour name, #RRGGBB, "rainbow", or "terminal" #[arg(short = 'C', long, default_value = "green")] pub(crate) color: String, @@ -132,6 +132,7 @@ pub(crate) const CYCLE: [Charset; 6] = [ pub(crate) struct Settings { pub(crate) base: (u8, u8, u8), pub(crate) rainbow: bool, + pub(crate) terminal: bool, pub(crate) depth: Depth, pub(crate) config: Config, pub(crate) frame: Duration, @@ -153,7 +154,12 @@ const MAX_STEP_FLOOR: f32 = 0.1; /// Pure argument validation, split out so it is testable without a terminal. pub(crate) fn validate(args: &Args) -> Result { - let BaseColor(base, rainbow) = BaseColor::from_str(&args.color).context("invalid --color")?; + let (base, rainbow, terminal) = + match BaseColor::from_str(&args.color).context("invalid --color")? { + BaseColor::Rgb(rgb) => (rgb, false, false), + BaseColor::Rainbow => ((0, 255, 65), true, false), + BaseColor::Terminal => ((0, 255, 65), false, true), + }; if !(0.0..=1.0).contains(&args.density) { bail!( @@ -234,6 +240,7 @@ pub(crate) fn validate(args: &Args) -> Result { Ok(Settings { base, rainbow, + terminal, depth, max_step, config: Config { @@ -319,6 +326,15 @@ mod tests { assert!(validate(&a).expect("valid").rainbow); } + #[test] + fn terminal_color_selects_the_terminal_palette_mode() { + let mut a = args(); + a.color = "terminal".into(); + let s = validate(&a).expect("valid"); + assert!(s.terminal); + assert!(!s.rainbow); + } + #[test] fn bad_density_is_rejected() { for d in [-0.1, 1.1, f32::NAN] { diff --git a/src/bin/rmatrix/main.rs b/src/bin/rmatrix/main.rs index 1a0dcf0..1182bfb 100644 --- a/src/bin/rmatrix/main.rs +++ b/src/bin/rmatrix/main.rs @@ -2,11 +2,12 @@ //! //! The binary is deliberately thin: all of the behaviour lives in the library //! so tests can drive it without a tty. What is left here is the event loop, -//! split across six modules so that no one file has to be read end to end: +//! split across seven modules so that no one file has to be read end to end: //! //! - [`cli`] — the flags, and `validate`, the only place input is rejected. //! - [`keys`] — telling a held key apart from a pressed one. //! - [`screensaver`] — what input ends `--screensaver` mode. +//! - [`palette`] — asking the terminal for its colours, for `--color terminal`. //! - [`term`] — raw mode, the alternate screen, teardown, and where frames go. //! - [`meter`] — the stats readout behind the `f` key. //! - [`bench`] — timing instrumentation behind the hidden `--bench` flag. @@ -17,6 +18,7 @@ mod bench; mod cli; mod keys; mod meter; +mod palette; mod screensaver; mod term; @@ -24,7 +26,7 @@ use anyhow::{Context, Result, bail}; use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::terminal; -use rmatrix::{Levels, Rain, Renderer, Theme}; +use rmatrix::{DEFAULT_TERMINAL_PALETTE, Levels, Rain, Renderer, Theme}; use std::process::ExitCode; use std::time::Instant; @@ -69,6 +71,11 @@ fn run(args: &Args, s: Settings) -> Result<()> { let mut charset_idx = CYCLE.iter().position(|c| *c == args.charset).unwrap_or(0); let mut theme = Theme::from_base(s.base, s.rainbow); + // Raw mode is on and the loop has not read input yet, which is the window + // the query needs; see palette.rs. + theme.terminal_palette = s + .terminal + .then(|| palette::query().unwrap_or(DEFAULT_TERMINAL_PALETTE)); theme.levels = args.levels.resolve(w, h); let mut bold = args.bold; let mut rain = Rain::new(w, h, s.config); diff --git a/src/bin/rmatrix/palette.rs b/src/bin/rmatrix/palette.rs new file mode 100644 index 0000000..91f96f0 --- /dev/null +++ b/src/bin/rmatrix/palette.rs @@ -0,0 +1,323 @@ +//! Asking the terminal for its own colours, for `--color terminal`. +//! +//! The six chromatic ANSI entries come back over the tty as OSC 4 replies. The +//! hard part is not parsing them but waiting for them: a terminal may answer +//! late, answer some and not others, or not answer at all, and every way of +//! getting that wrong leaves the program stuck or leaves stray bytes behind +//! for the key handler to read as typing. + +use rmatrix::Rgb; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Read, Write}; +use std::time::{Duration, Instant}; + +/// Red, yellow, green, cyan, blue, magenta: the chromatic entries. Black, +/// white and the greys are left out because rain in them reads as no colour. +const INDEXES: [u8; 6] = [1, 3, 2, 6, 4, 5]; + +/// Primary device attributes, sent after the palette queries. +/// +/// Every terminal in use answers DA1, and terminals answer in the order they +/// were asked, so its reply arriving means every palette reply that is coming +/// has already come. That is what makes the wait both quick and safe: quick +/// because a terminal that ignores OSC 4 is known the moment it answers DA1, +/// and safe because nothing is still in flight when we stop listening. +const DA1: &[u8] = b"\x1b[c"; + +/// How long to wait for a terminal that answers nothing at all, not even DA1. +/// +/// Any terminal that answers DA1 ends the wait as soon as it does, so this only +/// governs the silent case, where its cost is a slower start. A short timeout +/// is what hurts. A reply that lands after we stop listening is read by +/// crossterm as typing: the `r`, `b`, `c` and `f` in `rgb:cdcd/3131/3131` +/// toggle rainbow, bold, charset and stats, and under `--screensaver` the first +/// byte quits. The first version waited 100 ms, which an ssh round trip to the +/// other side of the world outlasts; a second does not. +const TIMEOUT: Duration = Duration::from_secs(1); + +/// How often to look again while waiting. +/// +/// The tty is read non-blocking and re-checked on this interval, rather than +/// waited on with poll(2), because macOS poll() does not work on terminals — +/// "The poll() system call currently does not support devices", `man 2 poll`. +/// It reports the tty readable at once, the read that follows blocks, and with +/// a terminal that did not answer, rmatrix sat on a blank screen until a key +/// was pressed and then swallowed the key. A sleep loop is crude, but it runs +/// once, at startup, for at most `TIMEOUT`. +const RECHECK: Duration = Duration::from_millis(2); + +/// More than this is not a palette reply; stop rather than buffer it. +const MAX_REPLY: usize = 4096; + +/// The terminal's six chromatic colours, or `None` if it does not say. +/// +/// Must run in raw mode, so the replies are neither echoed nor held back for a +/// newline, and before crossterm first reads input, so the two never compete +/// for the same bytes. +pub(crate) fn query() -> Option<[Rgb; 6]> { + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + // Opening /dev/tty creates a new open file description, and non-blocking is + // a property of the description, so this changes our handle only — never + // the stdin crossterm reads afterwards. + rustix::io::ioctl_fionbio(&tty, true).ok()?; + let mut ask = Vec::new(); + for index in INDEXES { + write!(ask, "\x1b]4;{index};?\x07").ok()?; + } + ask.extend_from_slice(DA1); + tty.write_all(&ask).ok()?; + tty.flush().ok()?; + parse_palette(&read_replies(&mut tty, TIMEOUT)) +} + +/// Everything the terminal sends until it has answered DA1, or `timeout` +/// passes. `tty` must be non-blocking: `WouldBlock` means nothing yet. +fn read_replies(tty: &mut impl Read, timeout: Duration) -> Vec { + let deadline = Instant::now() + timeout; + let mut bytes = Vec::new(); + let mut chunk = [0u8; 256]; + while !answered(&bytes) && bytes.len() <= MAX_REPLY && Instant::now() < deadline { + match tty.read(&mut chunk) { + Ok(0) => break, + Ok(n) => bytes.extend_from_slice(chunk.get(..n).unwrap_or_default()), + Err(e) if e.kind() == ErrorKind::WouldBlock => std::thread::sleep(RECHECK), + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(_) => break, + } + } + bytes +} + +/// Whether `bytes` holds a DA1 reply, `ESC [ ? c`. +fn answered(bytes: &[u8]) -> bool { + let mut rest = bytes; + while let Some(at) = rest.windows(3).position(|w| w == b"\x1b[?") { + rest = rest.get(at + 3..).unwrap_or_default(); + let end = rest.iter().find(|b| !b.is_ascii_digit() && **b != b';'); + if end == Some(&b'c') { + return true; + } + } + false +} + +fn parse_palette(bytes: &[u8]) -> Option<[Rgb; 6]> { + let mut colors = [None; 6]; + let mut start = 0usize; + while let Some(offset) = bytes.get(start..)?.windows(4).position(|w| w == b"\x1b]4;") { + let sequence_start = start + offset + 4; + let rest = bytes.get(sequence_start..)?; + let bel = rest.iter().position(|b| *b == b'\x07'); + let st = rest.windows(2).position(|w| w == b"\x1b\\"); + let end = match (bel, st) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }; + let Some(end) = end else { + break; + }; + if let Ok(reply) = std::str::from_utf8(&rest[..end]) + && let Some((index, rgb)) = parse_palette_reply(reply) + && let Some(slot) = INDEXES.iter().position(|candidate| *candidate == index) + { + colors[slot] = Some(rgb); + } + start = sequence_start + + end + + if rest.get(end) == Some(&b'\x07') { + 1 + } else { + 2 + }; + } + Some([ + colors[0]?, colors[1]?, colors[2]?, colors[3]?, colors[4]?, colors[5]?, + ]) +} + +fn parse_palette_reply(reply: &str) -> Option<(u8, Rgb)> { + let (index, spec) = reply.split_once(';')?; + let index = index.parse().ok()?; + let components = spec.strip_prefix("rgb:")?; + let mut parts = components.split('/'); + let r = scale_hex(parts.next()?)?; + let g = scale_hex(parts.next()?)?; + let b = scale_hex(parts.next()?)?; + if parts.next().is_some() { + return None; + } + Some((index, (r, g, b))) +} + +fn scale_hex(component: &str) -> Option { + if component.is_empty() + || component.len() > 4 + || !component.bytes().all(|b| b.is_ascii_hexdigit()) + { + return None; + } + let value = u32::from_str_radix(component, 16).ok()?; + let max = (1u32 << (component.len() * 4)) - 1; + Some(((value * 255 + max / 2) / max) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::io; + + /// A non-blocking tty that delivers `chunks` one read at a time, then has + /// nothing more to say: `None` in the script is a read that would block. + struct Tty(VecDeque>); + + impl Read for Tty { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self.0.pop_front() { + Some(Some(chunk)) => { + buf[..chunk.len()].copy_from_slice(chunk); + Ok(chunk.len()) + } + _ => Err(ErrorKind::WouldBlock.into()), + } + } + } + + fn tty(script: &[Option<&'static [u8]>]) -> Tty { + Tty(script.iter().copied().collect()) + } + + const PALETTE: &[u8] = b"\x1b]4;1;rgb:cdcd/3131/3131\x07\x1b]4;3;rgb:e5e5/e5e5/1010\x07\ + \x1b]4;2;rgb:0d0d/bcbc/7979\x07\x1b]4;6;rgb:1111/a8a8/cdcd\x07\ + \x1b]4;4;rgb:2424/7272/c8c8\x07\x1b]4;5;rgb:bcbc/3f3f/bcbc\x07"; + + #[test] + fn the_wait_ends_as_soon_as_the_terminal_answers_device_attributes() { + // Replies straggling in over several reads, with gaps, then DA1. The + // wait has to collect all of it and then stop at once, not sit out + // the timeout. + let mut t = tty(&[ + None, + Some(&PALETTE[..40]), + None, + None, + Some(&PALETTE[40..]), + Some(b"\x1b[?62;22;52c"), + ]); + let started = Instant::now(); + let replies = read_replies(&mut t, Duration::from_secs(30)); + assert!( + started.elapsed() < Duration::from_secs(1), + "sat out the timeout" + ); + assert_eq!( + parse_palette(&replies), + Some([ + (205, 49, 49), + (229, 229, 16), + (13, 188, 121), + (17, 168, 205), + (36, 114, 200), + (188, 63, 188), + ]) + ); + } + + #[test] + fn a_terminal_that_ignores_palette_queries_is_known_by_its_da1_reply() { + // Answering DA1 and nothing else means no palette is coming. Knowing + // that at once is what keeps startup instant on such terminals. + let mut t = tty(&[Some(b"\x1b[?1;2c")]); + let started = Instant::now(); + let replies = read_replies(&mut t, Duration::from_secs(30)); + assert!( + started.elapsed() < Duration::from_secs(1), + "sat out the timeout" + ); + assert_eq!(parse_palette(&replies), None); + } + + #[test] + fn a_terminal_that_says_nothing_is_given_up_on_at_the_timeout() { + // The bug this replaced: with a silent terminal on macOS the wait never + // ended until a key was pressed, and then it ate the key. + let mut t = tty(&[]); + let timeout = Duration::from_millis(30); + let started = Instant::now(); + let replies = read_replies(&mut t, timeout); + let took = started.elapsed(); + assert!(replies.is_empty()); + assert!(took >= timeout, "gave up after {took:?}"); + assert!( + took < timeout + Duration::from_secs(1), + "held on for {took:?}" + ); + } + + #[test] + fn only_a_complete_da1_reply_ends_the_wait() { + assert!(answered(b"\x1b[?62;22;52c")); + assert!(answered(b"noise\x1b]4;1;rgb:ff/00/00\x07\x1b[?1;2c")); + // Still arriving. + assert!(!answered(b"\x1b[?62;22")); + // Other replies that start the same way. A mouse report can arrive + // during the wait under --screensaver, and a keyboard-protocol reply + // ends in `u`. + assert!(!answered(b"\x1b[<35;10;5M")); + assert!(!answered(b"\x1b[?1u")); + } + + #[test] + fn osc_palette_replies_parse_in_any_order_with_bel_or_st() { + let replies = b"noise\x1b]4;5;rgb:aaaa/1111/bbbb\x1b\\\x1b]4;1;rgb:ff/00/00\x07\ + \x1b]4;4;rgb:1234/5678/9abc\x07\x1b]4;2;rgb:00/ff/00\x07\ + \x1b]4;6;rgb:00/ffff/ffff\x1b\\\x1b]4;3;rgb:ffff/8000/0000\x07"; + assert_eq!( + parse_palette(replies), + Some([ + (255, 0, 0), + (255, 128, 0), + (0, 255, 0), + (0, 255, 255), + (18, 86, 154), + (170, 17, 187), + ]) + ); + } + + #[test] + fn osc_rgb_components_scale_from_one_to_four_hex_digits() { + assert_eq!(parse_palette_reply("1;rgb:f/8/0"), Some((1, (255, 136, 0)))); + assert_eq!( + parse_palette_reply("2;rgb:ff/80/00"), + Some((2, (255, 128, 0))) + ); + assert_eq!( + parse_palette_reply("3;rgb:fff/800/000"), + Some((3, (255, 128, 0))) + ); + assert_eq!( + parse_palette_reply("4;rgb:ffff/8080/0000"), + Some((4, (255, 128, 0))) + ); + } + + #[test] + fn incomplete_or_malformed_palette_replies_use_the_fallback() { + assert_eq!(parse_palette(b"\x1b]4;1;rgb:ff/00/00\x07"), None); + for bad in [ + "1;rgba:ff/00/00", + "1;rgb:gg/00/00", + "1;rgb:ff/00", + "1;rgb:ff/00/00/ff", + "x;rgb:ff/00/00", + ] { + assert_eq!(parse_palette_reply(bad), None, "accepted {bad:?}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c8ac4cd..b72c991 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub use charset::Charset; pub use rain::{Config, Rain}; pub use render::{DEFAULT_COLOR_TOLERANCE, DrawStats, Renderer}; pub use theme::{ - BaseColor, DEFAULT_LEVELS, Depth, Levels, MIN_AUTO_LEVELS, Rgb, Theme, auto_levels, + BaseColor, DEFAULT_LEVELS, DEFAULT_TERMINAL_PALETTE, Depth, Levels, MIN_AUTO_LEVELS, Rgb, + Theme, auto_levels, }; pub use writer::FrameWriter; diff --git a/src/rain.rs b/src/rain.rs index 3b254ab..e53a1d7 100644 --- a/src/rain.rs +++ b/src/rain.rs @@ -308,7 +308,14 @@ impl Rain { if c.glow <= 0.0 { return None; } - let hue = self.cols.get(x as usize).map_or(0.0, |c| c.hue) + self.elapsed * 0.08; + // Only the rainbow turns. In terminal-palette mode the hue picks the + // column's colour, and a turning hue would re-pick it every frame. + let hue = self.cols.get(x as usize).map_or(0.0, |c| c.hue) + + if theme.rainbow { + self.elapsed * 0.08 + } else { + 0.0 + }; Some((c.ch, theme.color(c.glow, c.head, hue))) } } @@ -363,6 +370,101 @@ mod tests { assert_ne!(a, b); } + #[test] + fn rainbow_mode_sweeps_the_hue_across_the_screen() { + // The rainbow is a gradient: each column's hue is a step round the + // wheel from its neighbour's. The first cut of `--color terminal` gave + // every column a random hue instead, which turned the rainbow into + // noise, and the extra draws from the RNG changed what --seed replays. + fn hue((r, g, b): Rgb) -> f32 { + let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b)); + let max = r.max(g).max(b); + let d = max - r.min(g).min(b); + let h = if d == 0.0 { + 0.0 + } else if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + h / 6.0 + } + let w = 120; + let mut r = Rain::new( + w, + 30, + Config { + density: 1.0, + ..cfg(7) + }, + ); + for _ in 0..300 { + r.step(1.0 / 60.0); + } + let theme = Theme::from_base((0, 255, 65), true); + // The brightest body cell in each column carries its hue most cleanly. + let hues: Vec = (0..w) + .filter_map(|x| { + (0..r.height()) + .filter_map(|y| r.cell(x, y).filter(|c| !c.head).map(|c| (c.glow, y))) + .max_by(|a, b| a.0.total_cmp(&b.0)) + .and_then(|(_, y)| r.color_of(x, y, &theme)) + .map(|(_, c)| hue(c)) + }) + .collect(); + assert!(hues.len() > 100, "only {} columns lit", hues.len()); + let smooth = hues + .windows(2) + .filter(|p| { + let step = (p[1] - p[0]).rem_euclid(1.0); + step.min(1.0 - step) < 0.05 + }) + .count(); + assert!( + smooth * 10 >= (hues.len() - 1) * 9, + "only {smooth} of {} neighbouring columns were close in hue", + hues.len() - 1 + ); + } + + #[test] + fn terminal_colors_are_stable_per_column_and_vary_across_columns() { + let mut r = Rain::new( + 40, + 20, + Config { + density: 1.0, + ..cfg(7) + }, + ); + let mut theme = Theme::from_base((0, 255, 65), false); + theme.terminal_palette = Some(crate::theme::DEFAULT_TERMINAL_PALETTE); + for _ in 0..300 { + r.step(1.0 / 60.0); + } + + let colors: std::collections::BTreeSet = (0..r.width()) + .filter_map(|x| { + (0..r.height()).find_map(|y| { + r.color_of(x, y, &theme) + .map(|(_, color)| format!("{color:?}")) + }) + }) + .collect(); + assert!(colors.len() >= 4, "columns only used {colors:?}"); + + let before: Vec<_> = (0..r.width()) + .map(|x| (0..r.height()).find_map(|y| r.color_of(x, y, &theme).map(|v| v.1))) + .collect(); + r.elapsed += 100.0; + let after: Vec<_> = (0..r.width()) + .map(|x| (0..r.height()).find_map(|y| r.color_of(x, y, &theme).map(|v| v.1))) + .collect(); + assert_eq!(before, after, "terminal colors rotated over time"); + } + #[test] fn the_reported_speed_is_the_one_step_applies() { // The stats overlay prints `Rain::speed`. The moment that stops diff --git a/src/theme.rs b/src/theme.rs index c608bde..78d82d9 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,6 +11,18 @@ use thiserror::Error; pub type Rgb = (u8, u8, u8); +/// What `--color terminal` uses when the terminal will not say what its own +/// colours are: red, yellow, green, cyan, blue and magenta from VS Code's +/// default dark terminal theme, a palette most people have already seen. +pub const DEFAULT_TERMINAL_PALETTE: [Rgb; 6] = [ + (205, 49, 49), + (229, 229, 16), + (13, 188, 121), + (17, 168, 205), + (36, 114, 200), + (188, 63, 188), +]; + #[derive(Debug, Error, PartialEq, Eq)] pub enum ColorParseError { #[error("expected a colour name or #RRGGBB, got {0:?}")] @@ -138,6 +150,10 @@ pub struct Theme { /// The colour a trail decays toward before it is erased. pub dim: Rgb, pub rainbow: bool, + /// The terminal's own chromatic colours, for `--color terminal`; each + /// column takes one. `rainbow` wins when both are set, so the `r` key can + /// toggle away from the palette and back again. + pub terminal_palette: Option<[Rgb; 6]>, /// Quantisation steps; 0 disables quantisation entirely. pub levels: u16, } @@ -150,17 +166,23 @@ impl Theme { bright: base, dim: scale(base, 0.18), rainbow, + terminal_palette: None, levels: DEFAULT_LEVELS, } } /// `glow` 1.0 is freshly written, 0.0 is about to be erased. `hue_shift` - /// rotates the hue in rainbow mode and is ignored otherwise. + /// rotates the hue in rainbow mode, picks the column's entry from + /// `terminal_palette`, and is ignored otherwise. Any value is safe, NaN + /// and infinities included. #[must_use] pub fn color(&self, glow: f32, is_head: bool, hue_shift: f32) -> Rgb { let (bright, dim, head) = if self.rainbow { let b = hsv_to_rgb(hue_shift.rem_euclid(1.0), 0.85, 1.0); (b, scale(b, 0.18), head_tint(b)) + } else if let Some(palette) = self.terminal_palette { + let b = palette[palette_slot(hue_shift, palette.len())]; + (b, scale(b, 0.18), head_tint(b)) } else { (self.bright, self.dim, self.head) }; @@ -191,6 +213,32 @@ impl Theme { } } +/// Which palette entry a column with this hue takes. +/// +/// Column hues are `x / width`, so neighbours differ by a hair. Cutting [0, 1) +/// into six bands would paint the screen in six stripes, so the hue is hashed +/// instead: each column gets a colour that looks random but is a pure function +/// of where it is, so it holds still from frame to frame and nothing is drawn +/// from the RNG, which would change what `--seed` replays. A single multiply +/// was tried first and left 57% of neighbouring columns the same colour at +/// 80 wide; this mixer (Chris Wellons' `lowbias32`) leaves 11-15% across 40 to +/// 400 columns, against 17% for a fair die. +/// +/// `rem_euclid` can return exactly 1.0 for a hue a hair below zero, which is +/// why this reduces modulo `len` rather than scaling into it: the first version +/// indexed with `hue * 6` and panicked on `-1e-9`. +fn palette_slot(hue: f32, len: usize) -> usize { + // 2^16 steps tells apart every column of any terminal that exists. NaN + // casts to 0, which is as good a column as any. + let mut x = (hue.rem_euclid(1.0) * 65536.0) as u32; + x ^= x >> 16; + x = x.wrapping_mul(0x7feb_352d); + x ^= x >> 15; + x = x.wrapping_mul(0x846c_a68b); + x ^= x >> 16; + x as usize % len.max(1) +} + fn head_tint(base: Rgb) -> Rgb { lerp(base, (255, 255, 255), 0.80) } @@ -229,7 +277,11 @@ pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb { /// A parsed base colour plus whether the user asked for rainbow mode. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BaseColor(pub Rgb, pub bool); +pub enum BaseColor { + Rgb(Rgb), + Rainbow, + Terminal, +} impl FromStr for BaseColor { type Err = ColorParseError; @@ -246,7 +298,8 @@ impl FromStr for BaseColor { "orange" => (255, 140, 30), "purple" => (170, 90, 255), "white" => (220, 220, 220), - "rainbow" => return Ok(BaseColor((0, 255, 65), true)), + "rainbow" => return Ok(BaseColor::Rainbow), + "terminal" => return Ok(BaseColor::Terminal), hex => { let hex = hex.strip_prefix('#').unwrap_or(hex); if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { @@ -257,7 +310,7 @@ impl FromStr for BaseColor { (p(0), p(2), p(4)) } }; - Ok(BaseColor(rgb, false)) + Ok(BaseColor::Rgb(rgb)) } } @@ -298,27 +351,25 @@ mod tests { fn named_colors_parse() { assert_eq!( BaseColor::from_str("green"), - Ok(BaseColor((0, 255, 65), false)) + Ok(BaseColor::Rgb((0, 255, 65))) ); assert_eq!( BaseColor::from_str(" GREEN "), - Ok(BaseColor((0, 255, 65), false)) - ); - assert_eq!( - BaseColor::from_str("rainbow"), - Ok(BaseColor((0, 255, 65), true)) + Ok(BaseColor::Rgb((0, 255, 65))) ); + assert_eq!(BaseColor::from_str("rainbow"), Ok(BaseColor::Rainbow)); + assert_eq!(BaseColor::from_str("terminal"), Ok(BaseColor::Terminal)); } #[test] fn hex_colors_parse_with_and_without_hash() { assert_eq!( BaseColor::from_str("#ff8800"), - Ok(BaseColor((255, 136, 0), false)) + Ok(BaseColor::Rgb((255, 136, 0))) ); assert_eq!( BaseColor::from_str("FF8800"), - Ok(BaseColor((255, 136, 0), false)) + Ok(BaseColor::Rgb((255, 136, 0))) ); } @@ -367,6 +418,94 @@ mod tests { assert_eq!(t.color(1.0, false, 0.25), t.color(1.0, false, -0.75)); } + #[test] + fn no_hue_can_crash_terminal_mode() { + // The first version indexed the palette with `hue * 6`, and + // `(-1e-9f32).rem_euclid(1.0)` is exactly 1.0, so a library caller + // passing a hue a hair below zero got an index-out-of-bounds panic. + let mut t = Theme::from_base((0, 255, 65), false); + t.terminal_palette = Some(DEFAULT_TERMINAL_PALETTE); + for hue in [ + -1e-9, + -0.0, + 1.0, + 0.999_999_9, + -1e9, + 1e30, + f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + ] { + let c = t.color(1.0, false, hue); + assert!(DEFAULT_TERMINAL_PALETTE.contains(&c), "{hue}: {c:?}"); + } + } + + #[test] + fn rainbow_wins_over_the_terminal_palette_so_r_can_toggle_back() { + // `r` flips `rainbow` and nothing else. If the palette won, `r` would do + // nothing in terminal mode; if `r` cleared the palette, as it first + // did, there would be no way back to it. + let mut t = Theme::from_base((0, 255, 65), false); + t.terminal_palette = Some(DEFAULT_TERMINAL_PALETTE); + let hue = 0.3; + let palette = t.color(1.0, false, hue); + t.rainbow = true; + assert_eq!(t.color(1.0, false, hue), hsv_to_rgb(hue, 0.85, 1.0)); + t.rainbow = false; + assert_eq!(t.color(1.0, false, hue), palette); + } + + #[test] + fn neighbouring_columns_are_scattered_across_the_palette() { + // Column hues are x / width. Banding them into six would paint six + // stripes; the hash should look like a fair die instead, which matches + // a neighbour one time in six. + let mut t = Theme::from_base((0, 255, 65), false); + t.terminal_palette = Some(DEFAULT_TERMINAL_PALETTE); + for w in [40u16, 80, 120, 204, 400] { + let colors: Vec = (0..w) + .map(|x| t.color(1.0, false, f32::from(x) / f32::from(w))) + .collect(); + for base in DEFAULT_TERMINAL_PALETTE { + assert!(colors.contains(&base), "{w} wide never used {base:?}"); + } + let same = colors.windows(2).filter(|p| p[0] == p[1]).count(); + let share = same as f32 / f32::from(w - 1); + assert!(share < 0.25, "{w} wide: {share:.2} of neighbours match"); + } + } + + #[test] + fn terminal_mode_uses_each_palette_color_with_the_normal_continuous_ramp() { + let mut t = Theme::from_base((0, 255, 65), false); + t.terminal_palette = Some(DEFAULT_TERMINAL_PALETTE); + for (i, base) in DEFAULT_TERMINAL_PALETTE.iter().enumerate() { + // Columns are hashed onto the palette, so find one that lands here. + let hue = (0..1000) + .map(|n| n as f32 / 1000.0) + .find(|h| t.color(1.0, false, *h) == *base) + .expect("some column takes every palette entry"); + assert_eq!(t.color(1.0, true, hue), head_tint(*base)); + + let mut previous = -1.0; + let mut distinct = std::collections::BTreeSet::new(); + for step in 0..=20 { + let color = t.color(step as f32 / 20.0, false, hue); + assert!( + luma(color) >= previous, + "palette ramp dipped at step {step}" + ); + previous = luma(color); + distinct.insert(color); + } + assert!( + distinct.len() >= 8, + "palette color {i} only produced {distinct:?}" + ); + } + } + #[test] fn depth_detection_table() { assert_eq!(