Introduction
retroglyph is a 2D pseudographic terminal library for Rust: a double-buffered Terminal, styled
cells, text/layout helpers, and input events, generic over a pluggable Backend (a real terminal
via crossterm, or a native window / browser tab via software, gl, or wgpu). See the
API documentation for the full reference.
This book is for walkthroughs and explanations that don’t fit a doc comment. Code blocks embedded
here are pulled out of the workspace’s own compiled examples with mdBook’s {{#include}}, using
named anchors rather than copy-pasted snippets, so a renamed or removed API breaks this book’s build
(just book, part of just doc and just check) instead of leaving stale prose behind unnoticed.
For example, the draw step from 01_hello_world:
let mut surface = term.surface();
let row = Rect::new(0, 12, surface.width(), 1);
surface.print_aligned(row, "Hello, world!", HAlign::Center, Style::default());
See the sidebar for the tutorial (a walked-through game, chapter by chapter), how-to pages (one task each), and explanation pages (background on how a piece of retroglyph is designed).
1. Hello
This tutorial builds one small program across six chapters: a @ that starts on an empty screen,
learns to move, gets a map to walk around, then a camera, a UI, and finally ships to a browser. Each
chapter’s finished program lives in examples/tutorial/, compiled and run headless by CI, so every
code block below is pulled straight out of a file that actually builds – there is nothing here that
can quietly drift out of sync with the library. See the API documentation
for the full reference on anything named below.
The Example trait
Every runnable example in this workspace (tutorial included) implements one trait:
pub trait Example: Default + Sized + 'static {
const NAME: &'static str;
fn tick<B: Backend>(&mut self, term: &mut Terminal<B>, frame: &Frame) -> bool;
}
tick runs once per frame: read input, update state, draw, and return false to quit. It’s generic
over Backend, so the exact same tick runs against a real terminal (crossterm), a window
(software/gl/wgpu), or – as this book’s tests do – a headless backend with no display at
all. Chapter 6 leans on that directly; for now it just means nothing here is backend-specific code
to unlearn later.
State
Chapter 1’s state is empty: the @ never moves yet, so there’s nothing to remember between frames.
/// State for the hello example: none needed yet. `@` is drawn at a fixed spot; chapter 2 adds
/// a position here and moves it.
#[derive(Default)]
pub struct Hello;
Drawing
Terminal::surface hands out a Surface, the one drawing primitive in the library. Surface::put
places a single styled character at a cell:
/// Draws this frame (the driver presents).
#[allow(clippy::unused_self)]
fn draw<B: Backend>(&self, term: &mut Terminal<B>) {
let mut surface = term.surface();
let (x, y) = (surface.width() / 2, surface.height() / 2);
surface.put((x, y), '@', Style::default());
}
Running it
cargo run --example 01_hello --features crossterm # a real terminal
cargo run --example 01_hello --features software # a window
cargo run --example 01_hello # headless, prints a few frames to stdout
q, Escape, or the window’s close button quits. Nothing else happens yet – that’s chapter 2.
2. Input
Chapter 1’s @ sat still. This chapter gives it a position and moves it with the arrow keys –
still no map, just the four edges of the grid to bump into.
State
Hello’s empty struct becomes a position:
/// State for the input example: the player's position, in cells.
pub struct Input {
x: u16,
y: u16,
}
impl Default for Input {
fn default() -> Self {
// Centered on the 50x25 grid every backend in this crate uses by default; chapter 3's
// `init` override centers on the real backend size instead (see its own doc comment).
Self { x: 25, y: 12 }
}
}
Reading input and moving
Every backend’s Terminal::drain_events yields the same Event enum, so the input loop looks
identical whether it’s driven by real key presses, a synthetic test event, or (in chapter 6) a
browser’s keyboard events forwarded over WASM. try_move clamps the new position to the grid so @
can’t walk off the edge of the screen:
/// Moves the player by one cell in `(dx, dy)`, clamped to `width`x`height` so it never
/// leaves the grid.
fn try_move(&mut self, width: u16, height: u16, dx: i32, dy: i32) {
let nx = i32::from(self.x) + dx;
let ny = i32::from(self.y) + dy;
if let Ok(nx) = u16::try_from(nx)
&& nx < width
{
self.x = nx;
}
if let Ok(ny) = u16::try_from(ny)
&& ny < height
{
self.y = ny;
}
}
/// Drains pending input: moves on an arrow key, returns `false` if the user asked to quit.
fn handle_events<B: Backend>(&mut self, term: &mut Terminal<B>) -> bool {
let size = term.size();
let (width, height) = (size.width, size.height);
for event in term.drain_events() {
match event {
Event::Key(key) => match key.code {
KeyCode::Char('q') | KeyCode::Escape => return false,
KeyCode::Left => self.try_move(width, height, -1, 0),
KeyCode::Right => self.try_move(width, height, 1, 0),
KeyCode::Up => self.try_move(width, height, 0, -1),
KeyCode::Down => self.try_move(width, height, 0, 1),
_ => {}
},
Event::Close => return false,
_ => {}
}
}
true
}
Running it
cargo run --example 02_input --features crossterm
cargo run --example 02_input --features software
cargo run --example 02_input # headless fallback, prints a few frames to stdout
Arrow keys move @. q or Escape quits. Chapter 3 replaces the screen-edge clamp with a real map
and wall collision.
3. A map
Chapter 2 clamped @ to the screen edge. This chapter replaces that with an actual level: a walled
room built once from a plain string, with movement blocked by the walls in it instead of by the edge
of the grid.
Building the level
Grid::from_charmap turns a multi-line string into a Grid, calling a closure once per character
to decide that character’s tile. It’s the same helper the 11_sokoban gallery example (a complete
small game) builds its level from:
/// The level: a single walled room. `#` is a wall, everything else (just `.` here) is floor.
const LEVEL: &str = "\
#############
#............#
#............#
#............#
#............#
#............#
#############";
/// Builds the level's [`Grid`]: one [`Tile`] per character, walls and floor each with their own
/// style so they read apart from the player drawn on top of them.
fn build_level() -> Grid {
let wall = Style::default();
let floor = Style::default();
Grid::from_charmap(LEVEL, |c| match c {
'#' => Tile::new('#', wall),
_ => Tile::new('.', floor),
})
}
Movement and collision
try_move no longer clamps to the screen; it asks is_wall whether the destination cell is
walkable before committing to it. Out-of-bounds counts as a wall too, so a level doesn’t need a
border check of its own as long as it’s, well, walled in:
/// Whether `(x, y)` is a wall, or outside the level entirely (out of bounds is treated the
/// same as a wall: nothing to walk onto there).
fn is_wall(&self, x: i32, y: i32) -> bool {
let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) else {
return true;
};
self.level
.tile(0, (x, y))
.is_none_or(|tile| tile.glyph() == '#')
}
/// Moves the player by one cell in `(dx, dy)`, a no-op if the destination is a wall.
fn try_move(&mut self, dx: i32, dy: i32) {
let (nx, ny) = (i32::from(self.x) + dx, i32::from(self.y) + dy);
if self.is_wall(nx, ny) {
return;
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
{
self.x = nx as u16;
self.y = ny as u16;
}
}
/// Drains pending input: moves on an arrow key, returns `false` if the user asked to quit.
fn handle_events<B: Backend>(&mut self, term: &mut Terminal<B>) -> bool {
for event in term.drain_events() {
match event {
Event::Key(key) => match key.code {
KeyCode::Char('q') | KeyCode::Escape => return false,
KeyCode::Left => self.try_move(-1, 0),
KeyCode::Right => self.try_move(1, 0),
KeyCode::Up => self.try_move(0, -1),
KeyCode::Down => self.try_move(0, 1),
_ => {}
},
Event::Close => return false,
_ => {}
}
}
true
}
Drawing composes the two pieces from the last two chapters: Surface::blit stamps the whole level
grid onto the screen in one call, then @ is drawn on top of it the same way it always has been.
Running it
cargo run --example 03_a_map --features crossterm
cargo run --example 03_a_map --features software
cargo run --example 03_a_map # headless fallback, prints a few frames to stdout
Arrow keys move @, blocked by the walls (#). q or Escape quits.
This is the minimum viable tutorial: the how-to section covers what comes next task by task,
including drawing a status bar or log with retroglyph-ui widgets (see
Draw a panel and Handle a click).
Choose a backend
retroglyph ships six backend crates. All of them implement the same
Backend trait,
so the game code you write against one runs unchanged on any other. This page is about picking the
right one to start with, not about how any of them work internally; each crate’s own README covers
that.
The short answer
- Building a CLI tool or a TUI that lives in a real terminal:
crossterm. - Building something with sprites, smooth animation, or a resizable window, and it needs to run
natively:
softwarefirst,glorwgpuonly once you’ve measured a reason to move. - Building for the browser: see Run in a browser; the short version is
terminal-wasmfor a text UI,software/gl/wgpufor a canvas. - Writing tests:
Headless, fromretroglyph-core, no feature flag needed.
The decision
Does it need to run inside an actual terminal emulator (SSH session, tmux, a user’s shell)?
Use crossterm. It’s the only backend that reads
and writes a real terminal: raw mode, ANSI escapes, the terminal’s own resize events. software,
gl, and wgpu all open their own window and have nothing to do with a terminal emulator.
Otherwise, does it need pixel-level rendering: sprites, sub-cell offsets, custom fonts, smooth
scrolling? All three windowed backends (software, gl, wgpu) support this equally; a cell
backend (crossterm, terminal-wasm) can only ever draw one glyph per cell. Pick one of the three:
software: CPU rasterization viasoftbuffer. No GPU driver, no shader compilation, works everywherewinitopens a window (including wasm’s<canvas>). This is the right default until you have a concrete reason to leave it: most pseudographic games redraw at most a few thousand cells a frame, well within what CPU blitting handles at 60fps.gl: OpenGL 3.3 / WebGL2 viaglow. Instanced-quad rendering on the GPU. Reach for this once profiling showssoftware’s CPU cost is the bottleneck, or you specifically need OpenGL/WebGL2 for platform reasons (e.g. targeting hardware or browsers wherewgpu’s backend selection is unreliable).wgpu: Vulkan/Metal/D3D12 viawgpu. The modern-native equivalent ofgl; prefer it overglfor a new native-only project that wants GPU rendering and doesn’t need WebGL2 specifically.
Neither of those, and it’s a test? Use
Headless,
part of retroglyph-core itself with no extra crate to add. See Test a game.
Mixing backends in one binary
Nothing stops one binary from picking a backend at runtime (a --backend CLI flag, or falling back
from crossterm to software when stdout isn’t a TTY): Terminal<B> is generic over B: Backend,
so a small enum-dispatch wrapper or two separate code paths behind an early branch both work.
examples/src/launch.rs in this workspace picks a backend from Cargo features this way, as a
concrete reference.
See also
- Write a backend, if none of the six fit.
- Each crate’s own README (
crates/crossterm,crates/software,crates/gl,crates/wgpu,crates/terminal-wasm) for that backend’s specific setup and feature flags.
Test a game
retroglyph-core’s
Headless
backend is an in-memory Backend: no terminal, no window, no feature flag to enable. It’s the
backend to write your tests against, whichever real backend the game ships on.
Unit tests: drive a Terminal<Headless> directly
#![allow(unused)]
fn main() {
use retroglyph_core::backend::Headless;
use retroglyph_core::color::Style;
use retroglyph_core::terminal::Terminal;
let backend = Headless::new(20, 5);
let mut term = Terminal::new(backend);
term.draw(|s| s.put((2, 2), 'X', Style::default()))
.expect("draw failed");
term.present().expect("present failed");
insta::assert_snapshot!(term.backend().format_view());
}
Headless::format_view
converts the in-memory grid to a text string (spaces rendered as · so trailing/leading blanks are
visible in a diff), pairing naturally with insta::assert_snapshot! for
layout assertions. Colors and other styling have their own encoder,
Headless::format_styled,
for tests that also need to assert on foreground/background/attributes rather than plain glyphs.
Driving input
Headless::push_event
queues a synthetic event for your code’s own event loop to drain next tick, the same way a real
backend’s driver pushes an OS/terminal event. Prefer this over asserting only on an idle frame:
pushing real key/mouse events through the same Input::push_event path a windowed or wasm backend
uses is what actually proves your event handling and decoding logic, not just your draw code.
If the game is built around retroglyph-core’s App trait rather than a hand-rolled loop,
TestHarness
drives a full App::update/present cycle over Headless for you, including timing.
Reviewing snapshots
cargo install cargo-insta # one-time
cargo insta test # run tests and open the review UI
cargo insta accept # accept all pending snapshots
A failing snapshot test means the rendered output actually changed: review the diff
(cargo insta test shows it side by side) before accepting, rather than accepting on reflex. Commit
snapshot files alongside the tests that produce them, following the workspace’s existing layout
(crates/core/src/snapshots/, examples/tests/snapshots/).
Integration and cross-module tests
Unit tests live alongside their modules (#[cfg(test)] mod tests, in the same file as the code
under test). A few crates additionally have tests/*.rs integration suites for invariants that span
modules (e.g. crates/core/tests/no_drift.rs). Run everything with:
just test # run everything
just test-v # with stdout, useful while reviewing snapshot diffs
cargo test --lib # unit tests only
See also
- Choose a backend:
Headlessis the backend to test against regardless of which real backend the game ships on. - Record and replay:
TestHarness::replay/InputRecording(this page) cover replaying a recording back through a headlessAppin a test;retroglyph-recordercovers actually capturing a session to a file (InputRecorder) or exporting it as a docs GIF (FrameRecorder).
Record and replay
retroglyph-recorder is a separate crate on top of retroglyph-core, with two recorders covering
two different questions: InputRecorder for “what happened” (a real session’s input stream, for
turning a bug report into a regression test), FrameRecorder for “what was shown” (a backend’s
drawn output, for turning a real session into a docs GIF). Neither is enabled by default:
cargo add retroglyph-recorder
InputRecorder: turn a bug report into a regression test
Wraps any backend and taps its input stream, so a real session (a bug report, a demo run) can be
captured to a .rgrec file:
#![allow(unused)]
fn main() {
use retroglyph_core::backend::Headless;
use retroglyph_recorder::{InputRecorder, install_panic_recorder};
let recorder = InputRecorder::new(Headless::new(80, 24));
// Reach the recorder from anywhere (e.g. a panic hook) via a cheap, cloneable handle.
install_panic_recorder(recorder.handle(), "crash.rgrec");
// ...drive `recorder` as the backend, then save it whenever you like:
recorder.save("session.rgrec").expect("save recording");
}
install_panic_recorder
saves an in-progress recording automatically if the process panics, so the input that led to the
crash isn’t lost with it – the exact case a bug report needs.
Replay a saved session back into a regression test with retroglyph-core’s own testing feature,
against a headless App, at faithful (not coarse) timing and with no wall-clock sleeping:
#![allow(unused)]
fn main() {
use retroglyph_core::app::{App, Flow, Frame};
use retroglyph_core::testing::TestHarness;
struct MyApp;
impl<B: retroglyph_core::backend::Backend> App<B> for MyApp { fn update(&mut self, _t: &mut retroglyph_core::terminal::Terminal<B>, _f: &Frame) -> Flow { Flow::Exit } }
let recording = retroglyph_recorder::read("session.rgrec").expect("read recording");
let mut harness = TestHarness::from_recording(&recording);
let mut app = MyApp;
harness.replay(&recording, &mut app);
assert!(harness.view().contains("expected state"));
}
Or watch it happen on screen, live, through a real backend, with
replay_live
– see Test a game for the rest of TestHarness.
FrameRecorder: turn a session into a docs GIF
Wraps any backend and taps its Output::draw_layers diff stream – the same DrawCell stream every
backend’s draw_layers call already receives – buffering owned frames as it goes:
#![allow(unused)]
fn main() {
use retroglyph_core::backend::{DrawCell, Headless, Output};
use retroglyph_core::color::Style;
use retroglyph_core::grid::Pos;
use retroglyph_core::tile::Tile;
use retroglyph_recorder::{write_cast, FrameRecorder};
let mut recorder = FrameRecorder::new(Headless::new(20, 5));
let tile = Tile::new('!', Style::default());
recorder
.draw_layers(std::iter::once(DrawCell::new(Pos::new(2, 2), &tile)))
.expect("draw_layers failed");
let mut cast = Vec::new();
write_cast(&mut cast, recorder.inner().size(), &recorder.frames()).expect("write_cast failed");
}
write_cast exports the buffered frames as
asciicast v3 newline-delimited JSON – the format
agg, asciinema-player, and svg-term already render.
Nothing in retroglyph-recorder builds a GIF encoder or an interactive player; the point is
standard output that hands off to that existing ecosystem.
FrameRecorder’s captured frames are read through a
FrameRecorderHandle
taken out with recorder.handle() before handing the wrapped Terminal to a driver like
retroglyph_core::app::run_on, which takes it by value and never hands it back – the handle is how
the captured frames survive a driver call that consumes the recorder.
Two capture sources, one export format
- The example above is the
TestHarness-style source: scripted, deterministic, no real terminal needed. This is what makes docs GIF generation scriptable, instead ofvhs’s wall-clockSleep/Type@timing guesses (see retroglyph#461). - With the
ptyfeature,capture_ptyis a second capture source: a realportable-ptypseudo-terminal plus a realvt100VT-parser, feeding the samewrite_castpath. This is the mechanism aimed at matchingvhs’s real-terminal fidelity, without a real browser/ttyd/ffmpegin the loop. Seecapture_pty’s own rustdoc for the specific, measured fidelity gap against aTestHarness-driven capture of an equivalent session (color quantization differs; timing/frame boundaries differ under real scheduling) – both still converge on the samewrite_castoutput shape.
Generating a real docs GIF
retroglyph-examples’ launch::<E>() wires FrameRecorder in generically via --record <path>,
for the crossterm and headless-stdout backends (the two text/glyph-oriented ones – the windowed
software/GL/wgpu backends present pixels, not DrawCell diffs, so there’s nothing for a text export
to capture there):
cargo run --example 15_outpost_dashboard --features crossterm -- --record demo.cast
agg demo.cast demo.gif
just assets runs this same pipeline for the workspace’s own docs GIFs (agg is invoked as an
external tool, never a Cargo.toml dependency – it’s GPL-licensed, and this workspace is MIT).
Known limitation: whole session in memory
Both InputRecorder and FrameRecorder buffer their entire session in memory rather than streaming
to disk as it’s captured. Fine for the docs/demo-length and bug-report-length captures both exist
for; not a fit for an unbounded, long-running recording.
See also
- Test a game:
Headless/TestHarnessfor tests – this page’s counterpart for driving a real recorder instead.
Handle resize
A backend’s display can resize out from under you: a user resizes their terminal (crossterm) or
drags a window’s edge (software/gl/wgpu). retroglyph surfaces this as an ordinary event rather
than something you poll for, and one call resizes the grid to match.
Reacting to Event::Resize
Drain events every tick, same as any other input, and look for
Event::Resize.
It carries the new size in cells, already converted from whatever the backend measured (a real
terminal’s reported columns/rows, or a window’s physical pixel size divided by the font’s cell
size):
fn handle_events<B: Backend>(term: &mut Terminal<B>) -> bool {
let mut requested_size = None;
let mut quit = false;
for event in term.drain_events() {
match event {
Event::Key(key) if key.is_down() => {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Escape) {
quit = true;
}
}
Event::Close => quit = true,
Event::Resize(width, height) => requested_size = Some((width, height)),
_ => {}
}
}
if let Some((width, height)) = requested_size {
term.resize(width, height);
}
!quit
}
Apply it with
Terminal::resize,
not Backend::resize directly: Terminal::resize resizes the grid and calls the backend’s own
resize to keep
Backend::size
in sync. Calling Backend::resize on its own only updates what the backend reports; it does not
touch the grid, so your next draw call would still be working against the old dimensions.
Draw at term.area(), not a hardcoded size
The part that actually needs a resize handler to matter: never assume a fixed grid size. Read
Terminal::area
fresh every frame and lay out relative to it, so whatever size the resize handler applies is
immediately reflected in the next draw:
let area = term.area();
if area.width() == 0 || area.height() == 0 {
return;
}
Redraw the whole area on resize, not just what changed
Terminal::present only sends a backend the cells that changed since the last frame, and
Terminal::resize preserves overlapping content across the resize rather than clearing it. That
combination means a cell your draw code never explicitly touches keeps showing whatever was there
before, indefinitely, on any backend that doesn’t clear its own surface on resize. Shrinking a
window and then growing it back leaves stale glyphs sitting in what’s now the middle of the frame if
your draw code only paints an outline.
The fix isn’t a Terminal method: it’s that a resize-aware draw function fills its entire current
area every frame (background first, then whatever’s drawn on top), so every cell’s on-screen content
is explained by that frame’s own draw call. See examples/examples/14_resize.rs for a complete
example built around exactly this rule, runnable as:
cargo run --example 14_resize --features crossterm
cargo run --example 14_resize --features software
See also
- Choose a backend, if you’re deciding between a terminal and a windowed backend up front.
Use a tileset
The three windowed backends (software, gl, wgpu) can composite PNG sprite sheets over or
instead of the bitmap font, via each crate’s tilesets feature. crossterm and terminal-wasm
have no pixels to sprite, so the same draw call that places a sprite on a windowed backend falls
back to plain text glyphs there automatically, with no capability check or cfg on your part.
Loading a sheet
A tileset is a sprite sheet PNG sliced into equally sized tiles, each mapped to a glyph via a
Codepage.
Build one with
TilesetOptions::builder
and register it on the backend’s builder before opening the window:
#[cfg(any(feature = "software", feature = "gl", feature = "wgpu"))]
fn tilesets() -> [retroglyph_window::tileset::TilesetOptions; 2] {
use retroglyph_window::tileset::{Codepage, TilesetOptions};
let room = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/tileset.png")).to_vec();
let chest = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/chest.png")).to_vec();
[
TilesetOptions::builder(room)
.tile_size(8, 16)
.columns(2)
.codepage(Codepage::Custom(vec!['#', '.', '@', '$']))
.build()
.expect("room asset is a valid 16x32 PNG, evenly divisible into 8x16 tiles"),
TilesetOptions::builder(chest)
.tile_size(32, 32)
.columns(1)
.codepage(Codepage::Custom(vec!['[']))
.build()
.expect("chest asset is a valid 32x32 PNG holding one tile"),
]
}
Codepage::Custom maps tiles to specific characters, in sheet order (here, #/./@/$ for a
four-tile sheet). Codepage::Cp437 (the default) and Codepage::Unicode { start } are the other
two options, for sheets authored against one of those existing layouts instead.
Every graphical backend takes a TilesetOptions the same way, via
PresenterBuilder::tileset
(or, when writing code generic over the three backends, retroglyph_window::PresenterBuilder’s
configure-style hook, as examples/examples/07_sprites_tileset.rs’s own configure function does
to register the same tilesets across all three builder types).
Drawing a sprite
Draw a single-cell sprite with the glyph it’s mapped to, exactly like drawing any other cell:
surface.put((x, y), '#', Style::default()) picks up the wall sprite from the sheet above instead
of the font glyph, on every backend that has one.
A sprite larger than one cell (a chest, a portrait, a boss) uses
Surface::put_span
instead: it declares a footprint of (width, height) cells, anchored at one glyph, with the rest of
the footprint carrying the sprite’s text fallback (what a cell backend actually prints, one
character per covered cell). One call, no capability check: a pixel backend blits one sprite across
the whole footprint, a cell backend prints the fallback glyphs. Grid::span_owner resolves any cell
inside a multi-cell sprite back to its anchor in O(1), which is what you want for hit-testing (e.g.
“did the player step on any part of the chest”) instead of hand-rolled rectangle math.
Recoloring a sprite per cell
Surface::with_tint
recolors a sprite for one draw call, without touching the underlying tileset. Use
Tint::Multiply
to darken toward black (a torchlight falloff around the player, for example) or
Tint::Mix
to blend toward another color (highlighting an interactable object). Tints only affect sprites; a
cell backend, with no sprite pixels to recolor, renders unaffected.
examples/examples/07_sprites_tileset.rs is a complete, runnable reference for all of the above,
including multi-cell spans and tinting:
cargo run --example 07_sprites_tileset --features software
cargo run --example 07_sprites_tileset --features gl
cargo run --example 07_sprites_tileset --features wgpu
See also
- Choose a backend for which crate to add
tilesetsto.
Draw a panel
Panel, from
retroglyph-ui, is a bordered, optionally titled, filled rectangle: the box every other widget in
this crate typically sits inside. It’s a plain
Widget like the rest of
the crate: build it, then render it into a
Surface-shaped
area, no Backend type parameter involved.
Rendering into a sub-area
Surface::scope narrows a surface to one rectangle so a widget draws relative to that rectangle’s
own (0, 0) instead of the whole screen’s. 11_sokoban’s status pane is exactly this: a Panel
with a title, filled and bordered into the right-hand column reserved for it:
Panel::new()
.title("Status")
.render(&mut surface.scope(status_area));
status_area is one of the Rects a split_h call produced earlier in the same function; see
Split a layout for where that comes from. Content drawn after the Panel
call (the move counter, the key legend) is offset from status_area’s own top-left, one cell in
from the border it just drew, matching the interior inset Panel reserves for its box outline.
Styling and theming
Panel::border_style/fill_style set the outline and background directly; .title() (and
.add_title() for more than one, or a title on the bottom edge) adds a label into the top border.
For an app with more than one widget, prefer .theme() over hand-picking
border_style/fill_style so every widget’s panel matches the same palette; see
Theme a widget.
See also
- Split a layout, for the
RectaPanelrenders into. - Handle a click, for widgets that read pointer/keyboard input instead of only drawing.
examples/examples/11_sokoban.rs,examples/examples/09_widgets_dashboard.rs, andexamples/examples/17_theme_switch.rsfor complete panels in context.
Handle a click
retroglyph-ui has no retained widget tree: nothing remembers “button A is at this rectangle”
between frames. Instead,
Interaction tracks
pointer and keyboard state across frames, and each frame’s draw call re-declares where its widgets
are by calling into it, the same immediate-mode shape as drawing itself.
Pairing a surface with Interaction
Interaction::frame
wraps one frame: it hands back a
Ui, which pairs that frame’s
Surface with the Interaction context so a single call
(Ui::show) both
hit-tests and draws an
InteractiveWidget
like Button from the one
id/rect a call site names:
fn draw_button(ui: &mut Ui<'_, '_, ButtonId>, rect: Rect, id: ButtonId, label: &str) -> bool {
let theme = Theme::DARK;
let button = Button::new(label)
.style(Style::new().fg(theme.fg).bg(theme.panel_bg))
.hovered_style(Style::new().fg(theme.fg).bg(theme.hover_bg))
.pressed_style(Style::new().fg(theme.fg).bg(theme.press_bg))
.focused_style(Style::new().fg(theme.accent).bg(theme.panel_bg));
ui.show(rect, id, &button).clicked()
}
id is any Copy + Eq type the app defines (an enum listing every interactive widget on screen,
typically); Interaction<Id> uses it to track which widget is hovered, pressed, and focused across
frames, and to resolve Tab/Shift+Tab cycling between them. ui.show returns a
Response; .clicked()
is true for exactly one frame, the one where a press-then-release (or a drag that stayed inside
the widget) completed inside rect, or Enter/Space activated it while focused.
Before drawing, every event for the frame needs to reach the Interaction: feed each one to
ui.interaction().handle_event(event) (see 10_widgets_interaction.rs’s tick for the full
event-draining loop this snippet sits inside) so hover/press/focus state reflects that frame’s input
before any widget asks ui.show what happened to it.
Styling by response state
Button::style/hovered_style/pressed_style/focused_style (or .theme(), see
Theme a widget) pick the four states a click can pass through; the widget
itself decides which one applies each frame from the Response ui.show resolves internally, so
the call site never branches on hover/press by hand.
Running it
cargo run --example 10_widgets_interaction --features crossterm
cargo run --example 10_widgets_interaction --features software
cargo run --example 10_widgets_interaction # headless fallback, prints a few frames to stdout
See also
- Draw a panel, for widgets that only draw and never read
Interaction. examples/examples/09_widgets_dashboard.rsandexamples/examples/17_theme_switch.rsforInteractionshared across several widget kinds (Table,List,Tabs) in one frame.
Split a layout
retroglyph-ui’s layout engine divides one Rect into several, ratatui-style: no widget tree, no
retained layout state, just a function that takes an area and a list of constraints and hands back
the resulting Rects for that one frame.
split_h and split_v
split_h divides a Rect
into side-by-side columns;
split_v divides it into
stacked rows. Both take the same
Constraint list, one
per resulting pane, and hand back a Vec<Rect> in the same order:
fn draw_row(
surface: &mut Surface<'_>,
row: Rect,
caption: &str,
constraints: &[Constraint],
labels: &[&str],
) {
let style = Style::default();
surface.print((row.left(), row.top()), caption, style);
let body = Rect::new(row.left(), row.top() + 1, row.width(), row.height() - 1);
let panes = split_h(body, constraints);
for (pane, label) in panes.iter().zip(labels) {
Self::draw_pane(surface, *pane, label);
}
}
Choosing a Constraint
Constraint::Fixed(n)reserves exactlyncolumns/rows, first, regardless of the other panes.Constraint::Fill(weight)divides whatever’s left over after everyFixed/Min/Maxpane is reserved, in proportion to each pane’s own weight:Fill(1),Fill(2),Fill(3)splits the remainder 1:2:3, not into three equal thirds.Constraint::Min(n)/Constraint::Max(n)floor or cap a pane’s share of the fill remainder; they weigh1in that division regardless ofn.
19_weighted_fill is a complete, static reference for all four combined: equal thirds, a weighted
ratio, a Fixed pane plus a weighted remainder, and Min/Max mixed with Fill:
cargo run --example 19_weighted_fill --features crossterm
cargo run --example 19_weighted_fill --features software
cargo run --example 19_weighted_fill # headless fallback, prints a few frames to stdout
Nesting a split
Neither function is aware of the other: split_v’s output Rects are ordinary Rects, so a pane
from one call is exactly what the other call’s first argument expects. 11_sokoban’s screen layout
is a split_v (a one-row title bar over the rest of the screen) feeding one of its rows into a
split_h (the play field next to the status Panel), and there’s no limit to
how many levels deep that composes.
Layout: spacing and Flex alignment
Layout wraps
split_h/split_v with two optional builder calls, for the cases plain split_h/split_v don’t
cover:
Layout::vertical([Constraint::Fixed(1), Constraint::Fill(1)])
.spacing(1)
.flex(Flex::Center)
.split(area)
.spacing(n) carves a fixed-cell gap (or, via Spacing::Overlap(n), a shared border) between every
adjacent pair of panes; split_with_gaps returns those gap Rects alongside the panes for drawing
dividers into. .flex(Flex) controls where leftover space goes when constraints don’t consume a
Rect’s full extent (every pane is Fixed, say, and they don’t add up to the whole width):
Flex’s Start (the default,
matching plain split_h/split_v: leftover space trails after the last pane), Center, End, or
SpaceBetween/SpaceAround to distribute gaps between panes instead.
See also
- Draw a panel and Handle a click, for what typically goes inside a split pane.
retroglyph_core::grid::Rectitself, if a split’s ratio-based math is overkill and a layout is simpler to compute by hand.
Theme a widget
Every widget in retroglyph-ui picks its colors from whichever style knobs you set on it directly
(Button::style/hovered_style, Panel::border_style/fill_style, and so on), independent of any
other widget on screen. That’s fine for one widget; for an app with several, .theme() replaces the
per-widget, per-state style calls with one shared palette.
Theme
Theme is a plain struct of
named color roles (fg, panel_bg, hover_bg, press_bg, accent, and the rest); build one, or
start from
Theme::DARK
or
Theme::LIGHT.
Every widget that draws (Panel, Tabs, List, Button, ProgressBar, and the rest of the
widget module) has a
.theme(theme) builder method that derives every style it needs from that one Theme instead of
the widget’s own per-state defaults:
let panel = Panel::new()
.title(if *dark { "Theme: Dark" } else { "Theme: Light" })
.theme(theme);
ui.draw(panel_area, &panel);
// Panel's own interior inset -- one cell in from the border on every side, the same math
// `Modal::render` uses to hand back its inner content rect.
let inner = Rect::new(
panel_area.left() + 1,
panel_area.top() + 1,
panel_area.width() - 2,
panel_area.height() - 2,
);
let tabs = Tabs::new(&TABS).select(Some(selected_tab)).theme(theme);
ui.draw(
Rect::new(inner.left(), inner.top(), inner.width(), 1),
&tabs,
);
let list_area = Rect::new(inner.left(), inner.top() + 2, inner.width(), 4);
let list = List::new(&ITEMS).theme(theme);
ui.draw_stateful(list_area, &list, list_state);
draw_toggle_button(
ui,
Rect::new(inner.left(), inner.top() + 7, 20, 1),
theme,
dark,
);
let progress_area = Rect::new(inner.left(), inner.top() + 9, inner.width(), 1);
ui.draw(progress_area, &ProgressBar::new(7, 10).theme(theme));
Theme carries no reference to “the active theme”: nothing here is global or thread-local. Each
draw call is handed whichever Theme value the app currently considers active, picked however the
app likes (a config setting, a t keypress, matching the terminal’s own light/dark preference), and
every widget re-derives its colors from it fresh every frame.
Switching at runtime
Because .theme() takes a plain value with no persistent state of its own, switching themes is just
picking a different Theme before the next frame’s draw calls, including from inside a widget the
theme itself affects, like the toggle button below:
fn draw_toggle_button(ui: &mut Ui<'_, '_, WidgetId>, rect: Rect, theme: Theme, dark: &mut bool) {
let label = if *dark {
"Switch to Light"
} else {
"Switch to Dark"
};
let button = Button::new(label).theme(theme);
if ui.show(rect, WidgetId::ToggleButton, &button).clicked() {
*dark = !*dark;
}
}
Running it
cargo run --example 17_theme_switch --features crossterm
cargo run --example 17_theme_switch --features software
cargo run --example 17_theme_switch # headless fallback, prints a few frames to stdout
See also
- Draw a panel and Handle a click, for widgets typically themed together.
examples/examples/09_widgets_dashboard.rs/examples/examples/10_widgets_interaction.rsfor the hand-threadedtheme.*style calls.theme()replaces.
Run in a browser
Every backend compiles to wasm32-unknown-unknown. Which one to use depends on what the game looks
like in the browser: a text UI inside a terminal emulator widget, or a canvas.
Text UI: terminal-wasm
retroglyph-terminal-wasm implements Backend
directly, like Headless: there’s no event loop in this crate at all. A browser terminal emulator
(xterm.js, or any other; the crate has no dependency on one) is driven from JS, which calls in once
per animation frame to pull freshly rendered ANSI bytes and push back whatever input it collected.
On wasm32 the crate exposes free functions (wasm_terminal_new, wasm_terminal_resize,
wasm_terminal_push_key, wasm_terminal_take_output, plus mouse/paste/focus variants) that drive a
TerminalWasm by opaque handle. Here’s the crate’s own reference driver for xterm.js in full:
import init, {
wasm_terminal_new,
wasm_terminal_resize,
wasm_terminal_push_key,
wasm_terminal_take_output,
} from './pkg.js';
// `code` values above 0x110000 select a named key; see this crate's `key_codes` module for the
// full list (arrows, Home/End, F1-F24, etc).
const NAMED_KEY_BASE = 0x00110000;
const KEY_ENTER = NAMED_KEY_BASE + 1;
const KEY_BACKSPACE = NAMED_KEY_BASE;
// `mods` is a bitmask: SHIFT = 1, CONTROL = 2, ALT = 4, SUPER = 8.
function decodeXtermData(data) {
if (data === '\r') return { code: KEY_ENTER, mods: 0 };
if (data === '\x7f') return { code: KEY_BACKSPACE, mods: 0 };
// A single printable character forwards as its Unicode codepoint; xterm.js already resolves
// Shift into the codepoint itself (e.g. 'A' vs 'a'), so no SHIFT bit is needed here.
if (data.length === 1) return { code: data.codePointAt(0), mods: 0 };
return null;
}
async function main() {
await init();
const term = new Terminal({ cols: 80, rows: 24 });
term.open(document.getElementById('screen'));
const handle = wasm_terminal_new(term.cols, term.rows);
term.onData((data) => {
const key = decodeXtermData(data);
if (key) wasm_terminal_push_key(handle, key.code, key.mods);
});
window.addEventListener('resize', () => {
// Call whatever fit-to-container logic resizes `term` first (e.g. xterm.js's FitAddon), then
// tell the backend to match.
wasm_terminal_resize(handle, term.cols, term.rows);
});
function frame() {
const ansi = wasm_terminal_take_output(handle);
if (ansi) term.write(ansi);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
That’s a wiring template, not a full game: it plumbs input/output through the FFI but calls no
per-frame drawing logic of its own; that’s still your Rust code, holding a Terminal<TerminalWasm>
the same way it would hold a Terminal<Headless> in a test.
A game built on retroglyph-core’s App trait usually wants this crate’s app_entry! macro
instead of driving TerminalWasm by hand: it generates a single-instance-per-page FFI surface that
owns the Terminal<TerminalWasm> and drives App::update for you, including a backgrounded-tab
delta clamp. See docs.rs for both.
Canvas: software, gl, or wgpu
All three windowed backends port to wasm32 unchanged via winit’s web backend: the same
run_windowed/run_app call that opens a native window targets a <canvas> element in the browser
instead, with gl speaking WebGL2 and wgpu speaking WebGPU on that target. See
Choose a backend for which of the three to reach for. One behavioral
difference to know about porting from native: on wasm32 the browser owns frame pacing (winit
services each requested redraw on the next requestAnimationFrame), so WindowConfig::fit’s
target_fps cap is a native-only optimization; an app that relies on it to throttle below the
display refresh rate will run uncapped once it’s running in a browser tab.
Building and packaging
Add the wasm32-unknown-unknown target and wasm-bindgen, then build your binary/example the same
way you would natively, aimed at that target:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
cargo build --target wasm32-unknown-unknown --release --features software # or gl, wgpu, terminal-wasm
wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/your_game.wasm
wasm-bindgen’s output is an ES module (pkg/your_game.js) plus the .wasm binary; serve them
over real HTTP (fetch()-ing a .wasm module is blocked from a file:// origin) alongside an HTML
page that calls the generated init() before anything else. tools/build-wasm-example.sh in this
workspace is a complete, working reference for exactly this build-and-package step, used to produce
the live examples gallery: every example in this repo runs
there in all four wasm-capable variants (headless text, terminal-wasm, software canvas, gl
WebGL2) with no local toolchain required to try one.
See also
- Choose a backend for the terminal-vs-canvas decision in full.
- Handle resize: a browser window/canvas resizes the same way a native one
does, through
Event::Resize.
Write a backend
The six backends this workspace ships cover terminals, wasm terminals, and CPU/GPU windowed rendering. If none of them fit (a different terminal emulator protocol, a custom hardware display, an existing rendering pipeline you need to plug retroglyph into), implement the trait directly.
There are two levels to implement at, depending on what you’re building:
- A full
Backendfrom scratch, for anything that isn’t an existingwinitwindow: implementOutput,Input, andCursor. - A
Presenter, to drop into the existingretroglyph-windowwinitevent loop (run_windowed) and get windowing, input translation, and DPI handling for free: implementPresenter, anOutputsupertrait.
Output, Input, Cursor
Backend itself is a blanket impl with no methods of its own:
pub trait Backend: Output + Input + Cursor {}
impl<T: Output + Input + Cursor> Backend for T {}
so once a type implements all three facet traits, it’s a Backend; there’s nothing extra to write.
Output is the one required piece: draw cells to the display and flush them. The minimum
implementation is
draw_layers,
flush,
size,
and
clear.
Terminal::present always calls draw_layers, never draw directly: for a backend that renders
one glyph per cell (the common case, composites_layers left at its default false), present
pre-flattens every allocated layer into one before calling in, so layers above 0 still show up
without the backend doing any compositing itself. Only a pixel/GPU backend that needs true per-pixel
layering (transparency, sub-cell offsets bleeding between layers) needs to return true from
composites_layers and do that compositing itself.
Input needs only
poll_event.
If your backend never receives events from outside its own polling (reading a real terminal’s event
stream, for example), that’s the whole implementation:
push_event
defaults to a no-op. A backend fed externally (a window event loop’s callbacks, a test harness
injecting synthetic input) overrides push_event to queue what it’s handed for poll_event to
return later.
Cursor is entirely optional: impl Cursor for MyBackend {} is a complete implementation for a
backend with no text cursor to manage (any pixel/windowed backend where the game draws its own
cursor, if it wants one at all). Override
set_cursor_visible
and
set_cursor_position
for a backend that does manage one (a terminal’s own cursor, via its escape sequences).
Headless (crates/core/src/backend/headless.rs) is the shortest real implementation in the
workspace and the best reference to read start to finish: it implements all three traits in well
under 300 lines with no platform dependency at all.
Presenter
Presenter is an Output supertrait plus window-surface lifecycle methods, with no input methods
of its own (the retroglyph-window event loop owns input and forwards translated events into its
own queue). Implement:
init_surface: create your platform surface (a GPU context, a pixel buffer target) from theWindowHandlethe loop hands you.resize_surfaceandpresent.cell_size, in physical pixels, so the windowing layer can convert between window size and grid dimensions.
retroglyph-software’s SoftwareRenderer is the simplest of the three shipped Presenter
implementations (retroglyph-gl and retroglyph-wgpu are the other two) and a reasonable starting
point to read before writing a fourth.
See also
- Choose a backend: confirm none of the six shipped backends already fit before writing a new one.
Architecture
Terminal<B> owns a double-buffered Grid and the Backend lifecycle (resize, present, events).
Drawing itself goes entirely through Surface, handed out by Terminal::draw/ Terminal::surface:
a game calls term.draw(|s| { s.put(...); ... }) once per frame, and present diffs the current
frame against the previous one, sending only changed cells to the Backend. B is the only thing
that changes between a headless test and a real window or terminal:
┌───────────────────────────┐
│ App::update(...) │ game logic, once, generic over B
└──────────────┬─────────────┘
│ term.draw(|s| ...): writes through Surface
▼
┌───────────────────────────┐
│ Terminal<B> │ double-buffered Grid, cell diff
└──────────────┬─────────────┘
│ draw / draw_layers / poll_event
▼
┌───────────────────────────┐
│ B: Output + Input + Cursor │ the only piece that swaps out
└──────────────┬─────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
Headless (core) Crossterm SoftwareRenderer
in-memory grid, (retroglyph-crossterm) (retroglyph-software)
synthetic events real TTY, ANSI output winit window, pixels
Headless stores presented content in memory and lets tests inject synthetic Events with
Headless::push_event; nothing there talks to a real terminal or window. Swapping Headless for
Crossterm or SoftwareRenderer changes only the B type parameter: App implementations,
Terminal calls, and game logic are unchanged. run_on drives Terminal<Headless> and
Terminal<Crossterm> identically; the software backend’s windowed loop drives
Terminal<SoftwareRenderer> through the same App contract, inverted because winit owns the event
loop instead of handing control back to a driver function.
See examples/headless.rs (cargo run -p retroglyph-core --example headless) for the smallest
possible use of Headless, depending on nothing but retroglyph-core.
Input and Output are independent facets
Input and Output are independent facets of Backend, which does not fit a window as one type:
some event loop owns input, while a per-renderer surface owns output. WindowBackend
(retroglyph-window) reunites the two, implementing Output by delegating to its wrapped
Presenter, Input via its own event queue, and the no-op default Cursor, so Terminal gets the
full Backend it needs while renderer crates (retroglyph-software, retroglyph-gl,
retroglyph-wgpu) implement only Presenter.
Because WindowBackend owns input, a Presenter should not implement Input or Cursor
itself for windowed use: those impls would be dead (the event loop pushes to this queue, not the
presenter’s) and would silently miss the Mouse(Moved) coalescing that WindowBackend’s
push_event applies. A presenter that also wants a direct headless Terminal<Self> input path (as
retroglyph-software does for pixel tests) may still implement Input for that path, accepting
that a bare queue does not coalesce; a presenter with no such path (as retroglyph-gl) implements
only Presenter.
With the winit feature enabled, winit::run_windowed and winit::run_app own the event loop,
call push_event as winit events are translated, and call Presenter::present once per frame;
callers never touch WindowBackend directly. With winit disabled, retroglyph-window exports no
event loop at all: a caller driving its own loop (SDL2, tao, a custom driver) constructs
WindowBackend::new(presenter) itself, calls push_event for each translated input event, and
calls Terminal::present (which drives Presenter::flush) plus presenter_mut().present() once
per frame.
Presenting is automatic
winit::run_windowed and winit::run_app (and every _with_proxy/_on variant of either) call
Terminal::present for you, once, right after the app’s per-frame closure or App::update returns:
you no longer need to (and, for a stale-content bug fixed by this behavior, should not rely on
remembering to) call it yourself. Calling it yourself is still supported and has no ill effect (the
driver detects it already ran and skips its own call), for example if you also want to observe
Terminal::present’s Result directly.
For the App-based drivers (run_app and friends), the automatic present is skipped entirely on
Flow::Idle, leaving the previous frame on screen; every other Flow variant (including
Continue) presents as usual.
Coordinates
Sub-cell offsets aren’t shared code across backends
retroglyph-window’s Presenter trait specifies the sub-cell offset (Tile::dx/dy) and spill
contract once, so the CPU rasterizer (retroglyph-software) and the GPU ones (retroglyph-gl,
retroglyph-wgpu) produce the same pixels without mirrored per-backend comments that reference each
other and drift when only one is touched. What the contract does not specify is a shared
implementation: the GPU backends shift a quad’s vertex position in their vertex shader, while
retroglyph-software shifts origin_x/origin_y in a CPU blit. These are irreducibly different
mechanics that must nonetheless agree on the same four points (offsets are unscaled font pixels,
backgrounds stay unshifted, spill is uniform in all four directions, and a two-pass
background-then-glyph draw is what makes that uniformity possible); see the Presenter trait’s own
doc comment for those four points.