Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: software first, gl or wgpu only once you’ve measured a reason to move.
  • Building for the browser: see Run in a browser; the short version is terminal-wasm for a text UI, software/gl/wgpu for a canvas.
  • Writing tests: Headless, from retroglyph-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 via softbuffer. No GPU driver, no shader compilation, works everywhere winit opens 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 via glow. Instanced-quad rendering on the GPU. Reach for this once profiling shows software’s CPU cost is the bottleneck, or you specifically need OpenGL/WebGL2 for platform reasons (e.g. targeting hardware or browsers where wgpu’s backend selection is unreliable).
  • wgpu: Vulkan/Metal/D3D12 via wgpu. The modern-native equivalent of gl; prefer it over gl for 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: Headless is 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 headless App in a test; retroglyph-recorder covers 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 of vhs’s wall-clock Sleep/Type@ timing guesses (see retroglyph#461).
  • With the pty feature, capture_pty is a second capture source: a real portable-pty pseudo-terminal plus a real vt100 VT-parser, feeding the same write_cast path. This is the mechanism aimed at matching vhs’s real-terminal fidelity, without a real browser/ttyd/ffmpeg in the loop. See capture_pty’s own rustdoc for the specific, measured fidelity gap against a TestHarness-driven capture of an equivalent session (color quantization differs; timing/frame boundaries differ under real scheduling) – both still converge on the same write_cast output shape.

Generating a real docs GIF

retroglyph-exampleslaunch::<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/TestHarness for 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

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 Rect a Panel renders 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, and examples/examples/17_theme_switch.rs for 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.rs and examples/examples/17_theme_switch.rs for Interaction shared 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 exactly n columns/rows, first, regardless of the other panes.
  • Constraint::Fill(weight) divides whatever’s left over after every Fixed/Min/Max pane 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 weigh 1 in that division regardless of n.

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::Rect itself, 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.rs for the hand-threaded theme.* 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 Backend from scratch, for anything that isn’t an existing winit window: implement Output, Input, and Cursor.
  • A Presenter, to drop into the existing retroglyph-window winit event loop (run_windowed) and get windowing, input translation, and DPI handling for free: implement Presenter, an Output supertrait.

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:

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.