Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +89,8 @@ cargo build --release && cp target/release/rmatrix ~/.local/bin/
```
rmatrix [OPTIONS]

-C, --color <COLOR> Colour name, #RRGGBB, or "rainbow" [default: green]
-C, --color <COLOR> Colour name, #RRGGBB, "rainbow", or
"terminal" [default: green]
-c, --charset <SET> classic | katakana | ascii | alnum | binary | hex |
greek | symbols | custom [default: classic]
--custom <GLYPHS> Glyphs to use with `--charset custom`
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions src/bin/rmatrix/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand All @@ -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<Settings> {
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!(
Expand Down Expand Up @@ -234,6 +240,7 @@ pub(crate) fn validate(args: &Args) -> Result<Settings> {
Ok(Settings {
base,
rainbow,
terminal,
depth,
max_step,
config: Config {
Expand Down Expand Up @@ -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] {
Expand Down
11 changes: 9 additions & 2 deletions src/bin/rmatrix/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,14 +18,15 @@ mod bench;
mod cli;
mod keys;
mod meter;
mod palette;
mod screensaver;
mod term;

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;

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading