From c84f1798d9117d9d514e3c55e71517e99c74f49a Mon Sep 17 00:00:00 2001 From: Vision101 Date: Tue, 1 Sep 2026 21:46:18 -0300 Subject: [PATCH 1/4] Terminal color capability --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 5 +- src/bin/rmatrix/cli.rs | 21 +++++- src/bin/rmatrix/main.rs | 8 +- src/bin/rmatrix/term.rs | 157 +++++++++++++++++++++++++++++++++++++++- src/lib.rs | 3 +- src/rain.rs | 43 ++++++++++- src/theme.rs | 65 ++++++++++++++--- 9 files changed, 281 insertions(+), 23 deletions(-) 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..12ed09f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ anyhow = "1.0.104" clap = { version = "4.6.6", features = ["derive"] } crossterm = "0.29.0" rand = "0.10.2" +rustix = { version = "1.1.4", features = ["event", "fs", "termios", "time"] } 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 669f926..36938b1 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 six chromatic ANSI colors | | Reproducibility | — | `--seed` replays an identical animation | ## Install @@ -89,7 +89,7 @@ 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,6 +121,7 @@ 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 # random terminal-theme color per column, fading trail rmatrix -S 0.4 # slow motion; -S 0.05 is a crawl rmatrix -s # screensaver, exits on a keypress rmatrix --seed 1337 # same rain every time diff --git a/src/bin/rmatrix/cli.rs b/src/bin/rmatrix/cli.rs index 7a6b7dc..0880053 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,13 @@ 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 +241,7 @@ pub(crate) fn validate(args: &Args) -> Result { Ok(Settings { base, rainbow, + terminal, depth, max_step, config: Config { @@ -319,6 +327,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 bce37c4..30b8c29 100644 --- a/src/bin/rmatrix/main.rs +++ b/src/bin/rmatrix/main.rs @@ -22,7 +22,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; @@ -60,13 +60,16 @@ fn run(args: &Args, s: Settings) -> Result<()> { default_hook(info); })); - setup(args.bold)?; + let terminal_palette = setup(args.bold, s.terminal)?; let (tw, th) = terminal::size().context("querying terminal size")?; let (mut w, mut h) = (tw.max(1), th.max(1)); let mut charset_idx = CYCLE.iter().position(|c| *c == args.charset).unwrap_or(0); let mut theme = Theme::from_base(s.base, s.rainbow); + theme.terminal_palette = s + .terminal + .then_some(terminal_palette.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); @@ -141,6 +144,7 @@ fn run(args: &Args, s: Settings) -> Result<()> { rain.speed_mul = f32::from(d as u8 - b'0') / 3.0; } KeyCode::Char('r') => { + theme.terminal_palette = None; theme.rainbow = !theme.rainbow; renderer.resize(w, h); // force a full repaint } diff --git a/src/bin/rmatrix/term.rs b/src/bin/rmatrix/term.rs index 54666b1..81d2346 100644 --- a/src/bin/rmatrix/term.rs +++ b/src/bin/rmatrix/term.rs @@ -12,8 +12,15 @@ use crossterm::terminal::{ }; use crossterm::{ExecutableCommand, QueueableCommand, cursor}; use rmatrix::writer::{self, FrameWriter}; -use std::io::{Stdout, Write, stdout}; -use std::time::Duration; +use rmatrix::Rgb; +use rustix::event::{PollFd, PollFlags, poll}; +use rustix::time::Timespec; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Stdout, Write, stdout}; +use std::time::{Duration, Instant}; + +const PALETTE_INDEXES: [u8; 6] = [1, 3, 2, 6, 4, 5]; +const PALETTE_TIMEOUT: Duration = Duration::from_millis(100); /// Where drawn frames go. /// @@ -88,8 +95,105 @@ impl Sink { } } -pub(crate) fn setup(bold: bool) -> Result<()> { +/// Ask the terminal for its six chromatic ANSI palette entries. +/// +/// OSC 4 replies arrive on the controlling tty, not stdout. This runs before +/// crossterm starts its event reader so the two never compete for input. Failure +/// is deliberately silent: palette queries are optional terminal functionality. +pub(crate) fn query_palette() -> Option<[Rgb; 6]> { + let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty").ok()?; + for index in PALETTE_INDEXES { + write!(tty, "\x1b]4;{index};?\x07").ok()?; + } + tty.flush().ok()?; + read_palette(&mut tty, PALETTE_TIMEOUT) +} + +fn read_palette(tty: &mut File, timeout: Duration) -> Option<[Rgb; 6]> { + let deadline = Instant::now() + timeout; + let mut bytes = Vec::with_capacity(256); + let mut chunk = [0u8; 256]; + loop { + if let Some(palette) = parse_palette(&bytes) { + return Some(palette); + } + let remaining = deadline.checked_duration_since(Instant::now())?; + let timeout = Timespec::try_from(remaining).ok()?; + let mut fds = [PollFd::new(tty, PollFlags::IN)]; + if poll(&mut fds, Some(&timeout)).ok()? == 0 { + return None; + } + let n = tty.read(&mut chunk).ok()?; + if n == 0 { + return None; + } + bytes.extend_from_slice(&chunk[..n]); + if bytes.len() > 4096 { + return None; + } + } +} + +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) = PALETTE_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) +} + +pub(crate) fn setup(bold: bool, query_colors: bool) -> Result> { terminal::enable_raw_mode().context("entering raw mode")?; + let palette = query_colors.then(query_palette).flatten(); let mut out = stdout(); out.execute(EnterAlternateScreen)?; out.execute(DisableLineWrap)?; @@ -100,7 +204,7 @@ pub(crate) fn setup(bold: bool) -> Result<()> { if bold { out.execute(SetAttribute(Attribute::Bold))?; } - Ok(()) + Ok(palette) } /// The bytes that put the terminal back the way we found it. @@ -131,3 +235,48 @@ pub(crate) fn restore() { let _ = terminal::disable_raw_mode(); let _ = out.flush(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[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..5eeecda 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..4e3ae2c 100644 --- a/src/rain.rs +++ b/src/rain.rs @@ -151,8 +151,8 @@ impl Rain { self.h = h; self.cells = vec![Cell::default(); w as usize * h as usize]; self.cols = Vec::with_capacity(w as usize); - for x in 0..w as usize { - let hue = x as f32 / f32::from(w.max(1)); + for _ in 0..w as usize { + let hue = self.rng.random::(); self.cols.push(Column { drops: Vec::new(), // Stagger the start so the first frame isn't one flat wave. @@ -308,7 +308,8 @@ 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; + 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 +364,42 @@ mod tests { assert_ne!(a, b); } + #[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..bf2f1b7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,6 +11,17 @@ use thiserror::Error; pub type Rgb = (u8, u8, u8); +pub const DEFAULT_TERMINAL_PALETTE: [Rgb; 6] = [ + // The chromatic entries from the common xterm-compatible ANSI palette. + // Black, white and both greys are deliberately absent. + (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 +149,8 @@ pub struct Theme { /// The colour a trail decays toward before it is erased. pub dim: Rgb, pub rainbow: bool, + /// Chromatic palette used by `--color terminal`, one entry per column. + pub terminal_palette: Option<[Rgb; 6]>, /// Quantisation steps; 0 disables quantisation entirely. pub levels: u16, } @@ -150,6 +163,7 @@ impl Theme { bright: base, dim: scale(base, 0.18), rainbow, + terminal_palette: None, levels: DEFAULT_LEVELS, } } @@ -158,7 +172,10 @@ impl Theme { /// rotates the hue in rainbow mode and is ignored otherwise. #[must_use] pub fn color(&self, glow: f32, is_head: bool, hue_shift: f32) -> Rgb { - let (bright, dim, head) = if self.rainbow { + let (bright, dim, head) = if let Some(palette) = self.terminal_palette { + let base = palette[(hue_shift.rem_euclid(1.0) * palette.len() as f32).floor() as usize]; + (base, scale(base, 0.18), head_tint(base)) + } else 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 { @@ -229,7 +246,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 +267,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 +279,7 @@ impl FromStr for BaseColor { (p(0), p(2), p(4)) } }; - Ok(BaseColor(rgb, false)) + Ok(BaseColor::Rgb(rgb)) } } @@ -298,15 +320,19 @@ 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)) + Ok(BaseColor::Rgb((0, 255, 65))) ); assert_eq!( BaseColor::from_str("rainbow"), - Ok(BaseColor((0, 255, 65), true)) + Ok(BaseColor::Rainbow) + ); + assert_eq!( + BaseColor::from_str("terminal"), + Ok(BaseColor::Terminal) ); } @@ -314,11 +340,11 @@ mod tests { 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 +393,27 @@ mod tests { assert_eq!(t.color(1.0, false, 0.25), t.color(1.0, false, -0.75)); } + #[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 in 0..6 { + let hue = (i as f32 + 0.5) / 6.0; + assert_eq!(t.color(1.0, false, hue), DEFAULT_TERMINAL_PALETTE[i]); + assert_eq!(t.color(1.0, true, hue), head_tint(DEFAULT_TERMINAL_PALETTE[i])); + + 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!( From 12a12159e416ffdb539a6ab9b61e8a04e214b48e Mon Sep 17 00:00:00 2001 From: Vision101 Date: Wed, 2 Sep 2026 07:56:22 -0300 Subject: [PATCH 2/4] feat: add terminal-theme color mode with progressive fading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--color terminal` to query the terminal’s ANSI palette, assign random chromatic colors per column, and reuse the existing smooth fade and bright-head rendering. Include fallback behavior, tests, and README documentation. --- README.md | 4 ++++ src/bin/rmatrix/cli.rs | 13 ++++++------ src/bin/rmatrix/term.rs | 47 +++++++++++++++++++++++++++++------------ src/lib.rs | 4 ++-- src/rain.rs | 6 +++++- src/theme.rs | 26 +++++++++++------------ 6 files changed, 63 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 36938b1..864bd60 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,10 @@ rmatrix -s # screensaver, exits on a keypress rmatrix --seed 1337 # same rain every time ``` +`--color terminal` queries the terminal's ANSI palette using OSC 4. Terminals +that do not support palette queries use fallback colors, so the colors may not +match the terminal theme exactly. Smooth fading still works in either case. + 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 0880053..50fd9b6 100644 --- a/src/bin/rmatrix/cli.rs +++ b/src/bin/rmatrix/cli.rs @@ -154,13 +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 (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), - }; + 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!( diff --git a/src/bin/rmatrix/term.rs b/src/bin/rmatrix/term.rs index 81d2346..b22529a 100644 --- a/src/bin/rmatrix/term.rs +++ b/src/bin/rmatrix/term.rs @@ -11,8 +11,8 @@ use crossterm::terminal::{ LeaveAlternateScreen, }; use crossterm::{ExecutableCommand, QueueableCommand, cursor}; -use rmatrix::writer::{self, FrameWriter}; use rmatrix::Rgb; +use rmatrix::writer::{self, FrameWriter}; use rustix::event::{PollFd, PollFlags, poll}; use rustix::time::Timespec; use std::fs::{File, OpenOptions}; @@ -101,7 +101,11 @@ impl Sink { /// crossterm starts its event reader so the two never compete for input. Failure /// is deliberately silent: palette queries are optional terminal functionality. pub(crate) fn query_palette() -> Option<[Rgb; 6]> { - let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty").ok()?; + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; for index in PALETTE_INDEXES { write!(tty, "\x1b]4;{index};?\x07").ok()?; } @@ -152,19 +156,22 @@ fn parse_palette(bytes: &[u8]) -> Option<[Rgb; 6]> { }; if let Ok(reply) = std::str::from_utf8(&rest[..end]) && let Some((index, rgb)) = parse_palette_reply(reply) - && let Some(slot) = PALETTE_INDEXES.iter().position(|candidate| *candidate == index) + && let Some(slot) = PALETTE_INDEXES + .iter() + .position(|candidate| *candidate == index) { colors[slot] = Some(rgb); } - start = sequence_start + end + if rest.get(end) == Some(&b'\x07') { 1 } else { 2 }; + 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]?, + colors[0]?, colors[1]?, colors[2]?, colors[3]?, colors[4]?, colors[5]?, ]) } @@ -183,7 +190,10 @@ fn parse_palette_reply(reply: &str) -> Option<(u8, Rgb)> { } fn scale_hex(component: &str) -> Option { - if component.is_empty() || component.len() > 4 || !component.bytes().all(|b| b.is_ascii_hexdigit()) { + 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()?; @@ -261,9 +271,18 @@ mod tests { #[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)))); + 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] diff --git a/src/lib.rs b/src/lib.rs index 5eeecda..b72c991 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +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, DEFAULT_TERMINAL_PALETTE, 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 4e3ae2c..70bd211 100644 --- a/src/rain.rs +++ b/src/rain.rs @@ -309,7 +309,11 @@ impl Rain { return None; } 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 }; + + if theme.rainbow { + self.elapsed * 0.08 + } else { + 0.0 + }; Some((c.ch, theme.color(c.glow, c.head, hue))) } } diff --git a/src/theme.rs b/src/theme.rs index bf2f1b7..7714436 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -326,14 +326,8 @@ mod tests { BaseColor::from_str(" GREEN "), Ok(BaseColor::Rgb((0, 255, 65))) ); - assert_eq!( - BaseColor::from_str("rainbow"), - Ok(BaseColor::Rainbow) - ); - assert_eq!( - BaseColor::from_str("terminal"), - Ok(BaseColor::Terminal) - ); + assert_eq!(BaseColor::from_str("rainbow"), Ok(BaseColor::Rainbow)); + assert_eq!(BaseColor::from_str("terminal"), Ok(BaseColor::Terminal)); } #[test] @@ -397,20 +391,26 @@ mod tests { 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 in 0..6 { + for (i, base) in DEFAULT_TERMINAL_PALETTE.iter().enumerate() { let hue = (i as f32 + 0.5) / 6.0; - assert_eq!(t.color(1.0, false, hue), DEFAULT_TERMINAL_PALETTE[i]); - assert_eq!(t.color(1.0, true, hue), head_tint(DEFAULT_TERMINAL_PALETTE[i])); + assert_eq!(t.color(1.0, false, hue), *base); + 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}"); + 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:?}"); + assert!( + distinct.len() >= 8, + "palette color {i} only produced {distinct:?}" + ); } } From 79391feed60ed52c5f7c26725101f819c58fbb5d Mon Sep 17 00:00:00 2001 From: Derek Marshall Date: Fri, 16 Oct 2026 01:00:26 +0200 Subject: [PATCH 3/4] fix(palette): wait for the terminal's answer without poll(2) Two ways the palette query went wrong, both found by driving the binary from a pty that plays the terminal. On macOS, a terminal that does not answer OSC 4 left rmatrix on a blank screen until a key was pressed, and the key was swallowed: the first `q` did nothing and the second quit. The wait used poll(2) on /dev/tty, and macOS poll() does not support devices ("The poll() system call currently does not support devices", man 2 poll). It reports the tty readable at once, so the read() that follows blocks until something arrives. Replies that arrived after the 100 ms timeout were read by crossterm as typing. With the replies 200 ms away, which a long ssh round trip is, `-s` quit on its own at 0.2 s, and without `-s` the `b` in `rgb:` turned bold on (the `r`, `c`, `f` and digits toggle the rest). The query now sends DA1 after the six OSC 4 queries and reads until DA1 is answered. Terminals answer in order and every terminal in use answers DA1, so its reply means every palette reply that is coming has already come: a terminal that ignores OSC 4 is known at once, and nothing is left in flight to be mistaken for keys. The tty is read non-blocking and re-checked every 2 ms rather than polled, and a terminal that answers nothing at all is given up on after a second. On the same pty: a silent terminal now starts drawing within that second and the first `q` quits; one that only answers DA1 starts at once with the fallback colours; and on the 200 ms link the screensaver stays up, no stray keys arrive, and the rain is drawn in the terminal's own colours. The query moves out of term.rs into palette.rs, which leaves term::setup as it is on main, and runs after setup, before the loop first reads input. rustix stays as a direct dependency for the one call that sets non-blocking mode, but with no extra features: it is the crate crossterm already builds, so nothing new is compiled. The waiting rule is tested against a scripted tty. Ignoring the DA1 reply fails both DA1 tests; a busy-spin instead of the sleep, as a control, fails nothing. Co-Authored-By: Claude --- CLAUDE.md | 1 + Cargo.toml | 8 +- src/bin/rmatrix/main.rs | 10 +- src/bin/rmatrix/palette.rs | 323 +++++++++++++++++++++++++++++++++++++ src/bin/rmatrix/term.rs | 171 +------------------- 5 files changed, 342 insertions(+), 171 deletions(-) create mode 100644 src/bin/rmatrix/palette.rs 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.toml b/Cargo.toml index 12ed09f..97f3934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,13 @@ anyhow = "1.0.104" clap = { version = "4.6.6", features = ["derive"] } crossterm = "0.29.0" rand = "0.10.2" -rustix = { version = "1.1.4", features = ["event", "fs", "termios", "time"] } +# 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/src/bin/rmatrix/main.rs b/src/bin/rmatrix/main.rs index 19db114..792789e 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; @@ -62,16 +64,18 @@ fn run(args: &Args, s: Settings) -> Result<()> { default_hook(info); })); - let terminal_palette = setup(args.bold, args.screensaver, s.terminal)?; + setup(args.bold, args.screensaver)?; let (tw, th) = terminal::size().context("querying terminal size")?; let (mut w, mut h) = (tw.max(1), th.max(1)); 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_some(terminal_palette.unwrap_or(DEFAULT_TERMINAL_PALETTE)); + .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/bin/rmatrix/term.rs b/src/bin/rmatrix/term.rs index fe91045..22e0ac4 100644 --- a/src/bin/rmatrix/term.rs +++ b/src/bin/rmatrix/term.rs @@ -12,16 +12,9 @@ use crossterm::terminal::{ LeaveAlternateScreen, }; use crossterm::{ExecutableCommand, QueueableCommand, cursor}; -use rmatrix::Rgb; use rmatrix::writer::{self, FrameWriter}; -use rustix::event::{PollFd, PollFlags, poll}; -use rustix::time::Timespec; -use std::fs::{File, OpenOptions}; -use std::io::{Read, Stdout, Write, stdout}; -use std::time::{Duration, Instant}; - -const PALETTE_INDEXES: [u8; 6] = [1, 3, 2, 6, 4, 5]; -const PALETTE_TIMEOUT: Duration = Duration::from_millis(100); +use std::io::{Stdout, Write, stdout}; +use std::time::Duration; /// Where drawn frames go. /// @@ -96,115 +89,8 @@ impl Sink { } } -/// Ask the terminal for its six chromatic ANSI palette entries. -/// -/// OSC 4 replies arrive on the controlling tty, not stdout. This runs before -/// crossterm starts its event reader so the two never compete for input. Failure -/// is deliberately silent: palette queries are optional terminal functionality. -pub(crate) fn query_palette() -> Option<[Rgb; 6]> { - let mut tty = OpenOptions::new() - .read(true) - .write(true) - .open("/dev/tty") - .ok()?; - for index in PALETTE_INDEXES { - write!(tty, "\x1b]4;{index};?\x07").ok()?; - } - tty.flush().ok()?; - read_palette(&mut tty, PALETTE_TIMEOUT) -} - -fn read_palette(tty: &mut File, timeout: Duration) -> Option<[Rgb; 6]> { - let deadline = Instant::now() + timeout; - let mut bytes = Vec::with_capacity(256); - let mut chunk = [0u8; 256]; - loop { - if let Some(palette) = parse_palette(&bytes) { - return Some(palette); - } - let remaining = deadline.checked_duration_since(Instant::now())?; - let timeout = Timespec::try_from(remaining).ok()?; - let mut fds = [PollFd::new(tty, PollFlags::IN)]; - if poll(&mut fds, Some(&timeout)).ok()? == 0 { - return None; - } - let n = tty.read(&mut chunk).ok()?; - if n == 0 { - return None; - } - bytes.extend_from_slice(&chunk[..n]); - if bytes.len() > 4096 { - return None; - } - } -} - -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) = PALETTE_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) -} - -pub(crate) fn setup(bold: bool, screensaver: bool, query_colors: bool) -> Result> { +pub(crate) fn setup(bold: bool, screensaver: bool) -> Result<()> { terminal::enable_raw_mode().context("entering raw mode")?; - let palette = query_colors.then(query_palette).flatten(); let mut out = stdout(); out.execute(EnterAlternateScreen)?; out.execute(DisableLineWrap)?; @@ -219,7 +105,7 @@ pub(crate) fn setup(bold: bool, screensaver: bool, query_colors: bool) -> Result out.write_all(&mouse_capture_sequence())?; out.flush()?; } - Ok(palette) + Ok(()) } /// The bytes that make the terminal report the mouse to us. @@ -269,55 +155,6 @@ pub(crate) fn restore() { mod tests { use super::*; - #[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:?}"); - } - } - /// The DEC private modes a sequence sets (`ESC [ ? n h`) or resets /// (`ESC [ ? n l`). fn private_modes(bytes: &[u8], suffix: char) -> Vec { From 1fa2c21dc0bfaefb4182d4c165e9a6c0e002aab7 Mon Sep 17 00:00:00 2001 From: Derek Marshall Date: Fri, 16 Oct 2026 01:02:07 +0200 Subject: [PATCH 4/4] fix(theme): keep the rainbow and --seed as they were The first cut of `--color terminal` gave every column a random hue, drawn from the simulation's RNG on every resize, so that columns would land on scattered palette entries. That reached past terminal mode. The rainbow stopped being a rainbow. Its hue is `x / width` plus a slow turn, which sweeps the wheel across the screen; with a random hue per column it became noise. `--seed` stopped replaying what it used to. Every extra draw shifts the stream, and with it every number the README quotes: `examples/perf` went from 22,220 to 22,624 bytes per frame at 200x50, and from 36,857 to 37,334 unquantised, at every size and every --levels setting. Column hues are `x / width` again and nothing new is drawn from the RNG. Terminal mode scatters columns across the palette by hashing the hue instead, so each column still looks randomly coloured, keeps its colour from frame to frame, and costs the stream nothing. A single multiply left 57% of neighbouring columns the same colour at 80 wide; the lowbias32 mixer leaves 11-15% from 40 to 400 columns, where a fair die would give 17%. `examples/perf` now matches main to the byte at every row. Hashing modulo the palette length also fixes a panic through the library: the index was `hue * 6`, and `(-1e-9f32).rem_euclid(1.0)` is exactly 1.0, so a caller passing a hue a hair below zero got an out-of-bounds index. And rainbow now takes precedence over the palette, with `r` no longer clearing it, so `r` toggles away from the terminal's colours and back instead of discarding them for good. The README regains the truecolor/256/16 fallback note it lost, and says what happens on a terminal that will not report its palette. Each fix is guarded by a test, and each test was checked by putting the bug back: the random hue fails `rainbow_mode_sweeps_the_hue_across_the_ screen`; the `hue * 6` index fails `no_hue_can_crash_terminal_mode` and `neighbouring_columns_are_scattered_across_the_palette`; letting the palette win fails `rainbow_wins_over_the_terminal_palette_so_r_can_ toggle_back`. Co-Authored-By: Claude --- README.md | 14 ++--- src/bin/rmatrix/main.rs | 1 - src/rain.rs | 65 ++++++++++++++++++++++- src/theme.rs | 112 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 173 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9317d90..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`, rainbow, or your terminal's six chromatic ANSI colors | +| 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, "rainbow", or "terminal" [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,15 +122,16 @@ 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 # random terminal-theme color per column, fading trail +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` queries the terminal's ANSI palette using OSC 4. Terminals -that do not support palette queries use fallback colors, so the colors may not -match the terminal theme exactly. Smooth fading still works in either case. +`--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 diff --git a/src/bin/rmatrix/main.rs b/src/bin/rmatrix/main.rs index 792789e..1182bfb 100644 --- a/src/bin/rmatrix/main.rs +++ b/src/bin/rmatrix/main.rs @@ -157,7 +157,6 @@ fn run(args: &Args, s: Settings) -> Result<()> { rain.speed_mul = f32::from(d as u8 - b'0') / 3.0; } KeyCode::Char('r') => { - theme.terminal_palette = None; theme.rainbow = !theme.rainbow; renderer.resize(w, h); // force a full repaint } diff --git a/src/rain.rs b/src/rain.rs index 70bd211..e53a1d7 100644 --- a/src/rain.rs +++ b/src/rain.rs @@ -151,8 +151,8 @@ impl Rain { self.h = h; self.cells = vec![Cell::default(); w as usize * h as usize]; self.cols = Vec::with_capacity(w as usize); - for _ in 0..w as usize { - let hue = self.rng.random::(); + for x in 0..w as usize { + let hue = x as f32 / f32::from(w.max(1)); self.cols.push(Column { drops: Vec::new(), // Stagger the start so the first frame isn't one flat wave. @@ -308,6 +308,8 @@ impl Rain { if c.glow <= 0.0 { return None; } + // 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 @@ -368,6 +370,65 @@ 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( diff --git a/src/theme.rs b/src/theme.rs index 7714436..78d82d9 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,9 +11,10 @@ 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] = [ - // The chromatic entries from the common xterm-compatible ANSI palette. - // Black, white and both greys are deliberately absent. (205, 49, 49), (229, 229, 16), (13, 188, 121), @@ -149,7 +150,9 @@ pub struct Theme { /// The colour a trail decays toward before it is erased. pub dim: Rgb, pub rainbow: bool, - /// Chromatic palette used by `--color terminal`, one entry per column. + /// 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, @@ -169,15 +172,17 @@ impl Theme { } /// `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 let Some(palette) = self.terminal_palette { - let base = palette[(hue_shift.rem_euclid(1.0) * palette.len() as f32).floor() as usize]; - (base, scale(base, 0.18), head_tint(base)) - } else if self.rainbow { + 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) }; @@ -208,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) } @@ -387,13 +418,74 @@ 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() { - let hue = (i as f32 + 0.5) / 6.0; - assert_eq!(t.color(1.0, false, hue), *base); + // 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;