Skip to main content

Crate plurimus

Crate plurimus 

Source
Expand description

§Plurimus

Crate Badge Docs Badge CI Badge Deps Badge License Badge

A Bevy-native terminal renderer: cameras, widgets, and 2d/3d pipelines drawn to terminal cells.

The ratman example: a yellow ratatui rat and four colored bevy birds chasing it through a blue maze studded with cheese

Plurimus renders a Bevy world to the terminal the way bevy_render renders one to a window. The program is an ordinary Bevy App, and plurimus adds a render sub-app that turns that world into cells and presents them.

No single draw callback owns the frame. Any number of systems contribute to it, and one presenter writes to the terminal, diffing against the previous frame so only changed cells are sent. Multiple TerminalCameras with cell-space viewports split the screen the way multiple cameras split a window - a map view, a sidebar, and a minimap are three cameras, composited in order.

The cell model and widget ecosystem are ratatui’s: Buffer, Cell, Style, and the stock widgets, wrapped as components. Plurimus adds viewports and z-ordered composition, pointer routing and hit testing, focus and directional navigation, flexbox layout, world-space 2d, and GPU camera readback.

See ARCHITECTURE.md for how the crates fit together.

§Features

Each feature enables one crate, and the tiers stack. The default set - plurimus_core, plurimus_term, plurimus_crossterm - renders a live terminal app and reads its input. Anything not enabled is not compiled.

§Core (plurimus_core)

The render sub-app, cameras, compositing, and the presenter. Always compiled.

TerminalCamera carries an order (higher composites on top), a Viewport (Full, a Fixed cell rect, a Docked edge strip, or Fill for what remains), and a background. Entities carrying a UiWidget - any ratatui widget, including stateful ones - are placed by UiArea, ordered by UiOrder, and drawn every frame. The TerminalSize resource is the target’s cell dimensions, so a resize is a value change layout systems react to - written by plurimus_term when a real terminal reports one, and by the app otherwise.

The presenter is generic over ratatui’s Backend, so core renders without a terminal. Dependencies are bevy_app, bevy_ecs, bevy_color, and ratatui-core.

§Terminal (plurimus_term)

Everything that needs a terminal to mean anything, in both directions.

Inbound arrives in two shapes from the same events. Discrete messages - KeyMessage, MouseMessage (cell coordinates), PasteMessage, FocusMessage, TerminalResized - for text entry and keymaps; polled ButtonInput<KeyCode> and ButtonInput<MouseButton> for game loops.

Outbound is TerminalRequest: copy to a clipboard selection, set the window title. Best-effort, because nothing a terminal is asked can be confirmed.

InputCapabilities records what the terminal reports. Terminals implementing the kitty keyboard protocol give real press, repeat, and release; elsewhere releases are synthesized on a ReleaseTimeout, which is degraded but documented rather than silent. Losing focus releases every held key on any tier, since a terminal reports nothing while unfocused. MultiClickWindow is the other knob set once for every widget: how soon a second press has to land to count as a double-click, which no terminal reports either.

§Crossterm (plurimus_crossterm)

Terminal ownership: raw mode, alternate screen, mouse capture, bracketed paste, and the kitty keyboard protocol where available, restored on exit and on panic. It detects color support from the environment, translates crossterm events into input messages, reports resizes, and supplies the backend to core’s presenter.

The writer is generic: stdout by default, or the controlling terminal via CrosstermPlugin::tty() so stdout stays free for piped output.

§UI (plurimus_ui)

Interaction over any entity with an area. It computes widget areas, resolves hover with z-order hit testing, and routes pointer press, drag, release, click, and wheel events. Focus runs over bevy_input_focus, with a directional navigation map for arrow-key movement, scrolling through ScrollArea, ScrollIntoView and ScrollKeys - a wheel tick and a bound key both arriving as one ScrollBy - and the modal-overlay primitives menus and popovers are built from. A press carries how many have run together on it, so a double-click is the second Click reporting 2.

Keys are bound as data: a KeyBinding is a Key and the modifiers it must be pressed under, and first_bound is the scan every bindings component shares, so a widget written outside this workspace remaps the same way the stock ones do.

It also owns the styling contract a widget library builds on, rather than inventing its own: the UiTheme resource, UiStyle to patch one entity’s style, and StylistDisabled to take one entity’s look over entirely - plus the engine that consumes them. StylistCache records what a widget last drew and StylistCache::redraws is the comparison that keeps an idle frame free, so a widget library outside this workspace gets the same machinery the stock widgets use. A UiLabel is the text a stylist draws, a ratatui Line.

Nothing here is specific to stock widgets - an entity of your own with an area is hoverable, clickable, and focusable.

§Widgets (plurimus_widgets)

Buttons, checkboxes, radio groups, sliders, scrollbars, list boxes, tables, panes, menus, popovers, single-line text input, and a multi-line text editor. What is this crate’s own is the stylists themselves - one per widget, resolving the UiTheme and driving the cache that plurimus_ui owns.

The component and event vocabulary mirrors bevy_ui_widgets: widgets are stateless controllers emitting Activate and ValueChange, applied by the app for controlled behavior or by the stock *_self_update observers for uncontrolled. Every widget takes its keys from a bindings component - ActivateKeys, ListBoxKeys, TableKeys, SliderKeys, MenuKeys, TextInputKeys - each defaulting to what it always bound, so remapping a list to vim keys is a component swap rather than a rewrite.

§bevy_ui Layout (plurimus_bui)

bevy_ui’s layout stack - Node trees computed by taffy - run against terminal cameras at one pixel per cell, with backgrounds, borders, gradients, and text. Only layout runs: bevy_ui’s text, focus, picking, and asset systems stay out, and text is measured by grapheme width rather than font rasterization.

Use it instead of hand-computed Rects for responsive panels and nested rows and columns. Nodes bridge into the interaction routers, so they hover, click, and scroll like other widgets.

§2d Rendering (plurimus_2d)

Glyph, GlyphBlock, Pixel, and PixelBlock entities positioned by Transforms, projected per camera, so panning and zooming are camera properties. RenderLayers masks which cameras see which entities, and SubcellMode renders in halfblocks or braille for two or eight times the vertical resolution of a cell.

§3d Rendering (plurimus_3d)

A GPU camera read back and converted to cells. Render3dPlugins assembles a headless bevy render stack; the camera renders to an image, the pixels are read back, and a Strategy3d converts them - halfblock color, luminance ramps (ASCII, blocks, braille, shading), or depth ramps. Depth readback drives cross-camera occlusion and sobel edge overlays.

This is the only tier pulling in bevy_render and wgpu, and it needs a GPU adapter. Scene building stays with the app: plurimus stops at the render stack, and the app adds its material system (PbrPlugin) and asset loading such as bevy_gltf.

§Usage

§Adding it

[dependencies]
plurimus = "0.7"
bevy_app = "0.19"
bevy_ecs = "0.19"
ratatui-widgets = "0.3"

Plurimus does not re-export the bevy crates, the same way bevy_pbr does not re-export bevy_reflect: add whichever you use at bevy 0.19 and cargo unifies them with plurimus’s. ratatui_core is re-exported as plurimus::core::ratatui_core; the stock widget set is your own dependency unless the widgets feature is on, which re-exports it.

featurecrategives you
(none)plurimus_corerendering, always on
termplurimus_termthe terminal contract, both ways
crosstermplurimus_crossterma live terminal (implies term)
uiplurimus_uiinteraction, focus (implies term)
widgetsplurimus_widgetsstock controls (implies ui)
bevy-uiplurimus_buiflexbox layout (implies ui)
2dplurimus_2dworld-space sprites
3dplurimus_3dGPU camera readback

default = ["crossterm"]. default-features = false gives core alone, rendering into your own Backend.

§A first app

use std::time::Duration;

use bevy_app::{App, AppExit, ScheduleRunnerPlugin, Startup};
use bevy_ecs::prelude::Commands;
use plurimus::core::{CorePlugin, TerminalCamera, UiArea, UiWidget};
use plurimus::crossterm::CrosstermPlugin;
use ratatui_widgets::block::Block;
use ratatui_widgets::paragraph::Paragraph;

fn main() -> AppExit {
    let mut app = App::new();
    app.add_plugins((
        ScheduleRunnerPlugin::run_loop(Duration::from_millis(16)),
        CorePlugin,
        CrosstermPlugin::default(),
    ));
    app.add_systems(Startup, spawn_ui);
    app.run()
}

fn spawn_ui(mut commands: Commands) {
    commands.spawn(TerminalCamera::default());
    commands.spawn((
        UiWidget::new(Paragraph::new("hello, terminal").block(Block::bordered())),
        UiArea::Fill,
    ));
}

§Driving the frame loop

A terminal app has no window to pace it, so add ScheduleRunnerPlugin::run_loop(interval) from bevy_app or the app updates once and stops. The interval is the frame budget; the presenter writes only changed cells, so an idle screen stays cheap at any tick rate.

§Reading input

Both APIs come from the same events, so systems can mix them:

use bevy_ecs::prelude::{MessageReader, Res};
use plurimus::term::{ButtonInput, KeyCode, KeyKind, KeyMessage};

// Discrete: one action per press.
fn handle_commands(mut keys: MessageReader<KeyMessage>) {
    for key in keys.read() {
        if key.kind == KeyKind::Press && key.code == KeyCode::Char('r') {
            // reset something
        }
    }
}

// Polled: continuous, for movement.
fn move_player(keys: Res<ButtonInput<KeyCode>>) {
    if keys.pressed(KeyCode::Char('w')) {
        // step forward while held
    }
}

Polled keys are keyed by KeyCode::held_as, the key a character is held as: Char('w') stays held whether it was struck or released as w or W, so shift pressed midway through a hold does not strand it. Poll for the lowercase key - pressed(KeyCode::Char('W')) is never true.

§Exiting

No plurimus crate writes AppExit; exit policy belongs to the app. Write it from a key handler, and the crossterm tier restores the terminal on the way out.

§Splitting the screen

Viewports compose: dock a status strip to the bottom, dock a sidebar to the left, and let the main view Fill the rest, and the three stay correct across resizes. A higher order composites a camera on top, which is how overlays and modal layers are built.

Widgets drawn with default styling inherit whatever a 2d or 3d pipeline drew beneath them, so a HUD over a rendered world belongs on its own camera: a docked strip, or a transparent-background overlay.

§Testing headlessly

Nothing outside the presenter touches the terminal, so a test builds a real app, drives it, and reads the composed frame - no terminal and no CrosstermPlugin. The frame is a FrameBuffer resource in the render sub-app, holding the ratatui Buffer the presenter would have written:

use bevy_app::App;
use plurimus::core::{CorePlugin, FrameBuffer, TerminalRenderApp};
use plurimus::core::{TerminalCamera, TerminalSize, UiArea, UiWidget};
use ratatui_widgets::paragraph::Paragraph;

let mut app = App::new();
app.add_plugins(CorePlugin);
app.insert_resource(TerminalSize::new(24, 3));
app.world_mut().spawn(TerminalCamera::default());
app.world_mut().spawn((UiWidget::new(Paragraph::new("ready")), UiArea::Fill));
app.update();

let frame = &app.sub_app(TerminalRenderApp).world().resource::<FrameBuffer>().0;
let top_row: String = (0..frame.area.width)
    .filter_map(|x| frame.cell((x, 0)).map(|cell| cell.symbol()))
    .collect();
assert!(top_row.starts_with("ready"));

Input is injected the same way the backend delivers it - write a KeyMessage or MouseMessage into the world and run app.update().

§Examples

cargo run --example basic
cargo run --example headless --no-default-features
cargo run --example widgets --features widgets,bevy-ui
cargo run --example pong --features widgets,2d
cargo run --example ratman --features widgets,2d
cargo run --example lander --features widgets,3d

basic renders every stock ratatui widget as an entity in a grid on default features, with no ui or widgets crate, and re-lays the tiles on resize. q or ctrl-c quits.

headless is the lean tier: plurimus_core alone, driving two cameras, a hand-written TerminalWidget over the halfblock subcell grid, compositing and downsampling into a TestBackend that holds the cells in memory. It prints one frame and exits, and the --no-default-features invocation is the point - there is no terminal contract in the example’s own graph at all.

widgets runs the control library twice side by side: themed widgets at fixed cell rects on the left, the same widget logic under bevy_ui flex layout on the right. Tab and Shift-Tab move focus, arrows navigate and adjust the focused slider, Enter or Space activates, the mouse hovers, clicks, and drags, and a menu resets or disables every widget. Esc unfocuses; q with nothing focused quits. Wants roughly 80x30 or larger.

pong puts the 2d and ui pipelines in one camera: a halfblock ball and paddles in world space under a widget score line. W/S steps the left paddle, Up/Down the right, and r serves immediately.

ratman is a maze chase drawn entirely as halfblock pixel art: a ratatui rat eats cheese while four bevy birds hunt it, each sprite traced from its logo. Arrows or WASD steer, a power cheese turns the birds edible, and r starts over. Wants roughly 280x76 or larger.

lander flies a moon lander through the 3d pipeline with a widget HUD. W or space burns the main thruster, A/D tilt, t cycles the pixel-to-cell strategy, e cycles the sobel edge overlay, r resets. The first frames take a few seconds while GPU pipelines compile.

§Requirements

plurimusbevyratatui-core
0.70.190.1
  • Rust 1.95 or newer, edition 2024.
  • Bevy 0.19 for any bevy crates added alongside.
  • A terminal. Anything crossterm supports; the kitty keyboard protocol adds real key releases. Truecolor, 256-color, and 16-color terminals are detected and composited down.
  • A GPU adapter, for the 3d tier only. Every other tier is CPU-only.

§Status

Pre-1.0, versioned in lockstep across the workspace. The architecture is settled; the API still moves between minor releases, and CHANGELOG.md records what changed.

Types whose vocabulary is open carry #[non_exhaustive], so they can gain a field or a variant without a breaking release; each keeps a constructor or a Default you build from. Types an app must handle exhaustively to be correct are deliberately left open, so growth there is a compile error rather than a silently-taken _ arm. ARCHITECTURE.md states which are which.

§License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Re-exports§

pub use plurimus_core as core;
pub use plurimus_crossterm as crossterm;
pub use plurimus_term as term;