# terminal_graphics_interactive
> Keyboard and mouse input for terminal_graphics: build interactive terminal games, art, animations and drawing tools in Rust. Works in Windows PowerShell, Windows Terminal, Linux and macOS terminals, and over SSH. Its only dependency is terminal_graphics (re-exported), and the only `unsafe` is a few raw-mode calls.
It adds what `terminal_graphics` lacks: reading key presses and mouse events (click, drag, scroll). `Terminal::start()` switches to a full-screen session, `terminal.poll(timeout)` returns events and doubles as the frame timer, and `terminal.draw(&display)` shows a `Display` so that mouse positions line up with its cells. Everything from `terminal_graphics` is re-exported, so one dependency and one import is enough: `use terminal_graphics_interactive::prelude::*;` brings in `Display`, `Colour`, `Graphics`, `Container`, `Text`, `Rect` and `Ball` together with `Terminal`, `Event`, `Key`, `Mouse` and the rest.
Status: early (0.2), published on crates.io. Add it with `cargo add terminal_graphics_interactive`, or in `Cargo.toml`:
terminal_graphics_interactive = "0.2"
## Use it when
- You want a terminal program that reacts to the mouse or keyboard: a paint program, a game, a visualiser, a menu or a maths/physics toy.
- You want animation driven by input and a timer in one loop, with no async runtime and no other crates.
- You need it to behave the same in Windows PowerShell, Linux, macOS and over SSH.
## Do not use it when
- You only print output with no input. Use `terminal_graphics` alone.
- You need widgets, layout, text boxes or forms. Use a TUI framework.
- You need images in the terminal (Kitty, Sixel, iTerm2) or 24-bit colour. This is a grid of characters with the 16 ANSI colours.
- You need the program to keep running after the terminal closes, or to read from a pipe. `Terminal::start()` needs a real terminal.
## How it fits together
- `Terminal::start() -> io::Result<Terminal>`: enters the alternate screen, raw mode, mouse reporting and a hidden cursor, finds the window size, and reads input in the background. Dropping it restores everything, even on panic. It returns an error saying what to do if there is no terminal.
- `terminal.poll(timeout) -> io::Result<Option<Event>>`: waits up to `timeout`. `None` means the time ran out, which is the cue to advance an animation frame. `Duration::ZERO` checks without waiting. `terminal.wait() -> io::Result<Event>` blocks until something happens.
- `terminal.size() -> (u32, u32)`: window size in cells as `(width, height)`; `(80, 24)` if the terminal did not answer.
- `terminal.draw(&display) -> io::Result<()>`: shows a `Display` at the top left of the window. Call it once per frame.
- `Event` is `Event::Key(Key)`, `Event::Mouse(Mouse)` or `Event::Paste(String)`. Pasted text arrives as a single `Event::Paste`, not as key presses, so pasting `cargo run q` cannot trigger your key bindings or quit your program. `Event` can grow, so always end a `match` on it with a `_ => {}` arm.
- `Key` has `code: KeyCode` and `modifiers: Modifiers`; `key.is_char('q')` and `key.is_ctrl('c')` cover the common checks. `KeyCode` is `Char(char)`, `Enter`, `Tab`, `Backspace`, `Esc`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Insert`, `Delete` or `F(1..=12)`.
- `Mouse` has `kind: MouseKind`, `x`, `y` and `modifiers`. `MouseKind` is `Down(MouseButton)`, `Up(MouseButton)`, `Drag(MouseButton)`, `Moved`, `ScrollUp` or `ScrollDown`; `MouseButton` is `Left`, `Middle` or `Right`. `mouse.cell_in(&display) -> Option<(isize, isize)>` gives the display cell under the pointer, or `None` if it is outside.
- Lower level pieces are public too: `Parser` (`feed`, `next_event`, `flush`) turns input bytes into events, and `RawMode::enable()` is the raw-mode guard.
## Mistakes to avoid
- Ctrl+C does not stop the program while a `Terminal` exists. It arrives as a key event, so check `key.is_ctrl('c')` and leave your loop, or the user can only close the window.
- The display passed to `draw` must be at most one row shorter than the window height (`height - 1`) and no wider than the width, or it is drawn off screen.
- Mouse coordinates are 0-based cells with `(0, 0)` at the top left, the same as `Display`. Do not subtract 1.
- Raw mode turns off the terminal's own newline handling. If you print lines yourself, use `\r\n`, not `\n`. Drawing with `draw` is unaffected.
- Run in a terminal. Over SSH use `ssh -t host command`, or `Terminal::start()` returns an error. Under tmux or screen, turn their `mouse` option on to receive mouse events.
- A terminal that does not support bracketed paste (possibly the classic Windows console window) may send pasted text as ordinary key presses. Windows Terminal, iTerm2 and most Linux terminals are expected to send `Event::Paste`.
- Not every terminal sends every event: `MouseKind::Moved` (movement with no button held) is not reported, only presses, releases, drags and scrolling.
## Example: click to mark cells, q to quit
This is `examples/quickstart.rs`, which cargo compiles, so it is known to build.
```rust
use std::time::Duration;
use terminal_graphics_interactive::{Colour, Display, Event, MouseKind, Terminal};
fn main() -> std::io::Result<()> {
let mut terminal = Terminal::start()?;
// `draw` needs a display at most one row shorter than the window
let (width, height) = terminal.size();
let mut display = Display::new(width, height.saturating_sub(1));
loop {
terminal.draw(&display)?;
// wait up to 50 ms for input; `None` means a frame passed with no input
match terminal.poll(Duration::from_millis(50))? {
Some(Event::Key(key)) if key.is_char('q') || key.is_ctrl('c') => return Ok(()),
Some(Event::Mouse(mouse)) if matches!(mouse.kind, MouseKind::Down(_)) => {
if let Some((x, y)) = mouse.cell_in(&display) {
display.set_pixel(x, y, 'X', Colour::BrightYellow, Colour::Blue);
}
}
_ => {}
}
}
}
```
More examples: `cargo run --example paint` (a paint program), `cargo run --example screensaver` (bouncing dots with fading trails; shows the frame-timer loop: draw, handle input until the next frame is due, advance) and `cargo run --example events` (prints every event).
## Making art and fun graphics in a few lines
For animations, formulas and physics toys, do not write the loop yourself. `use terminal_graphics_interactive::prelude::*;` and use one of two entry points. Both start the terminal, run about 30 frames a second, track the mouse (including plain movement), quit on `q`, Esc or Ctrl+C, and put the terminal back afterwards.
**`sketch(formula)` / `sketch_with(palette, formula)`** fills the window every frame. The formula runs for every cell and returns a number from -1.0 to 1.0, which is what `sin` and `cos` give, so no rescaling is needed. NaN and out-of-range numbers are clamped, never a crash. It is given a `Cell` with `x` (column), `y` (row, stretched by two so one unit looks as tall as one unit of `x` is wide, which keeps circles round), `t` (seconds since the start), `mouse_x`, `mouse_y` (same units as `x` and `y`), `mouse_down`, `width`, `height`, and `cell.dist_to_mouse()`.
A `Palette` turns a number into a colour: `Palette::ocean()`, `fire()`, `rainbow()`, `mono()`, or your own with `Palette::new(&[Colour::Black, Colour::Red, ...])`, dark to bright. A terminal has 16 colours, so values between two colours are drawn with shade blocks (`░▒▓█`) for about four times as many steps. Fewer than two colours is repaired, not a panic. To colour cells yourself, `palette.shade(v)` (v from 0.0 to 1.0) and `palette.shade_signed(v)` (-1.0 to 1.0) return the `(character, foreground, background)` to give `display.set_pixel`.
```rust
use terminal_graphics_interactive::prelude::*;
fn main() -> std::io::Result<()> {
// Move the mouse to send ripples across it. Press q to quit.
sketch_with(Palette::fire(), |cell| {
let ripple = cell.dist_to_mouse() * 0.25 - cell.t * 3.0;
((cell.x * 0.15 + cell.t).sin() + (cell.y * 0.2 - cell.t * 0.7).sin() + ripple.sin()) / 3.0
})
}
```
**`World::new()...run()`** is a physics playground described by chained settings: `.balls(n)` (how many, up to 64), `.size(radius)` (try 3.5 for big balls), `.gravity(g)` (try 0.02), `.mouse_pulls()` (the pointer attracts the balls, the right button pushes them away), `.trails()` (glowing tails) and `.palette(p)`. Balls bounce off the walls and off each other, and a click adds a ball. `World::new().run()` already looks good.
```rust
use terminal_graphics_interactive::prelude::*;
fn main() -> std::io::Result<()> {
// The mouse pulls the balls in, the right button pushes them away, a click
// adds a ball. Press q to quit.
World::new()
.balls(5)
.size(3.5)
.gravity(0.02)
.mouse_pulls()
.trails()
.run()
}
```
To test or benchmark art without a terminal, use the headless API: `sketch_frame(&mut display, &palette, t, &pointer, formula)` draws one frame of a sketch into a `Display`, and `world.simulation(seed)` (end a `World::new()` chain with `.simulation(1)` instead of `.run()`) gives a `Simulation` you advance with `resize(w, h)`, `step(&pointer)` and `render(&mut display)` and inspect with `positions()`. The same seed always gives the same world.
Pointer movement with no button held is not reported by default; `sketch` and `World` turn it on. In your own loop, call `terminal.report_mouse_movement()`.
To add a palette, an effect or a setting, see CONTRIBUTING.md.
## Games
A game keeps state, has several screens (title, play, pause, game over), reads held keys and scores points. It runs at a fixed 60 updates a second. Import it with `use terminal_graphics_interactive::game::*;`, which also brings in the whole art prelude.
Use games when your program keeps state, has several screens, scores points or reads player input over time. Use art when it is a formula or a physics toy with no state to manage (`sketch`, `World`). Do not use games for a plain full-screen text UI or a widget toolkit; use `Terminal` directly or a TUI framework.
Read `examples/sail.rs` (the flagship: art in games, with a sky and a sea from formulas, pixel-art boat and island, and pooled fireballs with particles) and `examples/glow.rs` (art in a game, in about 50 lines) to see the pieces used together. `examples/sail_together.rs` plays `sail` for two to four players over the network.
The pieces, all in `game::*`:
- `Game` is a trait with `update(&mut self, ctx) -> Next` and `draw(&self, screen)`: one screen of your game. `Next` says what happens next: `Next::stay()`, `Next::switch(game)`, `Next::push(game)` (pause on top), `Next::pop()` or `Next::quit()`.
- `Context` (the `ctx` argument) holds `ctx.keys`, `ctx.pointer`, `ctx.dt`, `ctx.tick`, `ctx.size`, `ctx.seed`, `ctx.players`, `ctx.me` and `ctx.time()`.
- `run` plays a game in the terminal. `Headless` plays it without a terminal, for tests: `Headless::new(game).size(40, 12)`, then `tap(code)` presses and releases a key. `step` runs updates (`step(60)` is one second) and `text` returns what was drawn, row by row. Its rustdoc has more examples.
- `Screen` draws: `clear`, `text(x, y, ...)`, `text_centred(y, ...)`, `put(x, y, ch, colour)`, plus `fill`, `bar` and `sprite`. `Sprite` (`Sprite::new(art, colour)`) makes a picture from a string. `PixelArt` (`PixelArt::new(art, &[('R', Colour::Red)])`) is a picture in square pixels, drawn with `screen.pixel_art(x, y, &art)`, and `screen.disc` and `screen.glow` light round shapes the same way.
- `Keys` (`ctx.keys`): `held(KeyCode::Left)`, `pressed(KeyCode::Enter)`, `released(...)`, and `axis(KeyCode::Left, KeyCode::Right)` gives -1, 0 or 1. `Controls` names actions that players can rebind: `Controls::new().bind(action, &[keys])`, then `held(&ctx.keys, action)`.
- `Rng` is deterministic random numbers: `range_i32(lo, hi)`, `chance(p)`, `pick(&items)`. `Timer` (`Timer::every(seconds)` or `Timer::once(seconds)`) is a cooldown and clocks in game seconds: `tick(ctx.dt)` returns true when one completes.
- `Pool` (`Pool::with_capacity(n)`) is a fixed list of objects that never reallocates: `push(item)`, `retain`, `iter`.
- `Hit` is a shape for collisions: `Hit::rect(x, y, w, h)` or `Hit::circle(x, y, r)`, then `hit.overlaps(&other)`. `Tuning` exposes numbers a tuner can change while the game runs: `Tuning::new().knob(name, label, default, min, max)`, read with `tuning.get(name, fallback)` (the website sliders use it).
- `Sim` holds the rules apart from the picture: `type Input` (small, `Encode + Decode + Clone + Default`), `step(&mut self, inputs, rng)` and `hash(&self, h)`. Inside `step` use the given `rng`, never the clock or the window size, and `game::math::{sin, cos, exp}` instead of the `f32` methods, which can differ between machines. `Headless` plays a `Game`; a `Sim` needs no terminal at all, so a test or a bot just calls `step`.
- `Lockstep::new(sim, transport, Config::new(players, me))` plays one `Sim` on several machines by sharing only inputs: call `lockstep.tick(input, |sim| ..)` every update; it returns `Progress` (`Advanced`, `Stalled`, `Desync` naming the steps, or `Lost`). `Tcp` (`Host::bind(addr)?.accept(players, timeout)` and `Tcp::join(addr, timeout)`) or `Local` (in-process, for tests) carry the bytes, and `announce`/`await_message` share the seed. `examples/sail_together.rs` is a complete match.
Rules that are easy to get wrong:
- `Ctrl+C` always quits. `q` and Esc belong to the game, so the game decides what they do.
- `update` runs 60 times a second and `ctx.dt` is constant (1/60). Never read the wall clock; use `ctx.time()`.
- `draw` takes `&self`, so it cannot change the game. Change state in `update` only.
- Terminal cells are about twice as tall as wide, so move in rows at about half the speed of columns. Game objects are coloured regions, not text: draw them with `PixelArt`, `disc`, `glow` or `Particles`, and keep `text` for the HUD and labels.
- Where the terminal cannot report key release, `Keys::held` is emulated by timing: a press counts as held for 300 ms and each repeat extends it. The kitty keyboard protocol gives real releases.
- Use `Rng::new(ctx.seed)` once, in your first scene, and never `std::time` or `thread_rng`, so a run replays exactly.
- A `Pool` never grows: push returns the item back when it is full, so decide what to do with it.
The complete game below is `examples/quickgame.rs`. Cargo compiles it and its tests play it headless. Run it with `cargo run --example quickgame`.
```rust
//! QUICKGAME: steer a `@` with the arrows or WASD and collect the `*` stars
//! for 20 seconds. Enter starts, q quits, Ctrl+C always quits.
use terminal_graphics_interactive::game::*;
fn main() -> std::io::Result<()> {
run(Title { score: None })
}
/// The title (public so the website can start the game here). After a round it shows the score.
pub struct Title {
pub score: Option<u32>,
}
impl Game for Title {
fn update(&mut self, ctx: &mut Context<'_>) -> Next {
if ctx.keys.pressed(KeyCode::Enter) {
Next::switch(Play::new(ctx.seed))
} else if ctx.keys.pressed(KeyCode::Char('q')) {
Next::quit()
} else {
Next::stay()
}
}
fn draw(&self, screen: &mut Screen) {
screen.clear(Colour::Black);
screen.text_centred(8, "QUICKGAME", Colour::BrightYellow);
let line = match self.score {
Some(score) => format!("time up: {score} stars"),
None => "collect the stars before time runs out".to_string(),
};
screen.text_centred(10, &line, Colour::White);
screen.text_centred(12, "press Enter to play", Colour::BrightWhite);
}
}
/// One round: steer, collect stars, beat the clock.
struct Play {
rng: Rng,
x: f32,
y: f32,
score: u32,
time_left: f32,
stars: Pool<(i32, i32)>,
}
impl Play {
fn new(seed: u64) -> Play {
Play {
rng: Rng::new(seed),
x: 20.0,
y: 8.0,
score: 0,
time_left: 20.0,
stars: Pool::with_capacity(12),
}
}
}
impl Game for Play {
fn update(&mut self, ctx: &mut Context<'_>) -> Next {
if ctx.keys.pressed(KeyCode::Char('q')) {
return Next::quit();
}
let dx = ctx.keys.axis(KeyCode::Left, KeyCode::Right)
+ ctx.keys.axis(KeyCode::Char('a'), KeyCode::Char('d'));
let dy = ctx.keys.axis(KeyCode::Up, KeyCode::Down)
+ ctx.keys.axis(KeyCode::Char('w'), KeyCode::Char('s'));
// rows move half as fast: a cell is about twice as tall as it is wide
let (cols, rows) = (ctx.size.0 as i32, ctx.size.1 as i32);
let max_x = (cols - 1).max(0) as f32;
let max_y = (rows - 1).max(0) as f32;
self.x = (self.x + dx.clamp(-1, 1) as f32 * 24.0 * ctx.dt).clamp(0.0, max_x);
self.y = (self.y + dy.clamp(-1, 1) as f32 * 12.0 * ctx.dt).clamp(0.0, max_y);
let player = (self.x.round() as i32, self.y.round() as i32);
let before = self.stars.len();
self.stars.retain(|star| *star != player);
self.score += (before - self.stars.len()) as u32;
if ctx.tick % 36 == 0 {
let star = (self.rng.range_i32(0, cols), self.rng.range_i32(0, rows));
let _ = self.stars.push(star); // a full pool gives the star back
}
self.time_left -= ctx.dt;
if self.time_left <= 0.0 {
return Next::switch(Title {
score: Some(self.score),
});
}
Next::stay()
}
fn draw(&self, screen: &mut Screen) {
screen.clear(Colour::Black);
for &(x, y) in self.stars.iter() {
screen.put(x, y, '*', Colour::BrightYellow);
}
let (x, y) = (self.x.round() as i32, self.y.round() as i32);
screen.put(x, y, '@', Colour::BrightCyan);
let hud = format!("score {} time {}", self.score, self.time_left.ceil());
screen.text(0, 0, &hud, Colour::White);
}
}
```
## Links
- Source and issues: https://github.com/calum/terminal_graphics_interactive
- The graphics crate it builds on, re-exported: https://docs.rs/terminal_graphics and https://crates.io/crates/terminal_graphics