tt-toe/bevy_tui_texture/bevy-tui-texture
Claude Code and agent skill by tt-toe/bevy_tui_texture
- community
Checking that the SKILL.md file is still in the repository.
Render ratatui terminal UIs as GPU textures inside a Bevy app (2D UI nodes, 3D quads, or existing glTF meshes). Use this skill whenever code in this repo (or a consumer of the bevy_tui_texture crate) spawns, draws, resizes, or handles input for an in-game terminal — including WASM/WebGL2 builds. Covers the declarative API (TuiRequest): no material touching, no RenderDevice/RenderQueue parameters in user code.
Install
npx vskill@latest install tt-toe/bevy_tui_texture/bevy-tui-textureSKILL.md
bevy_tui_texture — agent usage guide
Versions: bevy 0.20 + ratatui 0.30.2 + wgpu 30 (wgpu must exactly match bevy's pin — bump together; the crate re-exports wgpu and ratatui so downstream code can name matching types without pinning its own copies).
Mental model
- A terminal is a `Tui` component on an ordinary entity. Query it like
anything else (Query<&mut Tui, With<MyMarker>>).
tui.draw(|frame| ...)is pure CPU — it fills ratatui's buffer and
marks the component dirty only if cells actually changed (byte-identical redraws are free; calling it every frame is the intended pattern).
- The plugin renders dirty terminals entirely in the render world,
directly into the destination Image's GpuImage — in the same frame as the draw, before the camera passes (a structural guarantee from render-graph set ordering). There is no CPU readback, no GPU→GPU copy, and no material touching anywhere — any material type (StandardMaterial, ExtendedMaterial, fully custom) updates automatically with zero registration.
- User systems never need
RenderDevice,RenderQueue,Assets<Image>,
Assets<Mesh>, or Assets<StandardMaterial> — not at spawn time, not per frame.
- Per-terminal GPU state (one instance buffer, a globals uniform) is
created lazily on first render and evicted automatically when the Tui's last Handle<Image> drops. The glyph atlas + compositor pipelines are shared across every terminal using the same Fonts (keyed by font identity, not by terminal) and evicted once no live Tui uses that font. No manual cleanup either way.
Choosing a spawn API (top of the ladder first)
- `TuiRequest` (default choice) — spawn the component, the plugin
materializes the terminal on the next frame:
TuiRequest::ui(cols, rows, fonts)→ bevy_ui terminal (ImageNode+
required Node; your own Node in the spawn tuple wins).
TuiRequest::world_quad(cols, rows, fonts, height)→ 3D quad sized
in world units (height; width follows texture aspect). Face normal is local `+Z` — orient with Transform::from_translation(pos) .with_rotation(Quat::from_rotation_arc(Vec3::Z, camera_pos - pos)). Transform::looking_at is the OPPOSITE convention and shows the back.
TuiRequest::headless(cols, rows, fonts)→ aTuiwith no surface —
for AttachTerminal targets (glTF screens) or fully custom meshes/materials.
- Non-default config via
.with_config(TerminalConfig { ... }).
- `AttachTerminal { terminal, material }` — put a headless
Tui's
content on an existing mesh entity (e.g. a glTF primitive). The system re-claims automatically until the async glTF loader stops overwriting the material, then goes idle (no archetype churn once settled). AttachMaterial::standard(AlphaMode::Opaque) or ::custom(|image| MyMaterial { ... }). For a self-illuminating screen, route image through the material's EMISSIVE channel (black base_color, white emissive) so content stays visible regardless of scene lighting — see claim_object2_screen in examples/retro_crt.rs.
- **`TerminalTexture::create(cols, rows, fonts, programmatic_glyphs,
transparent_reset_bg, initial_fill, &mut images) + Tui::from_texture_state(...)** — low-level escape hatch only (see examples/tui_component.rs, examples/shader_mesh.rs`).
For custom render passes / shaders, tui.image_handle() exposes the destination Handle<Image>; look up its GpuImage in the render world and use the re-exported wgpu types against it.
Core recipes
Spawn (no render resources in the signature — this is the whole point):
Per-frame draw (register in TerminalSystemSet::Render or UserUpdate):
Runtime resize — tui.request_resize(cols, rows) (no GPU work at call site; Image is recreated at the same handle, TerminalDimensions and world-quad mesh aspect update automatically; same-size requests are no-ops). No auto-fit helper exists by design: convert InputEvent::Resize { pixels } via fonts.min_width_px() / fonts.height_px() — full recipe in examples/resize.rs.
Transparency (HUD-style see-through screen):
Only ratatui::style::Color::Reset backgrounds (ratatui's default) become transparent; explicit background colors stay opaque. initial_fill: [u8; 4] sets the pre-first-draw clear color (default opaque black); initial_draw: Some(Box::new(|frame| ...)) draws real content before the first presented frame (closure must be + Send + Sync).
Fonts:
- Native embedded:
Font::new(include_bytes!(...))(needs'static). - Runtime bytes:
Font::from_vec(vec)— neverBox::leakfor this. - Async/Wasm-safe: `TuiFontSource::Asset { handle:
asset_server.load("fonts/x.ttf"), size_px: 16 } — the request stays pending until the .ttf loads, then materializes. (Named TuiFontSource, not FontSource — bevy's prelude already has a FontSource` and glob imports would collide.)
- CJK/fallback:
fonts.add_regular_fonts([...])etc.; the
bold_italic_fonts feature enables real bold/italic font slots (add_bold_fonts/add_italic_fonts/add_bold_italic_fonts), otherwise bold/italic are faked from regular.
Nerd Fonts icons work with no feature flag: register a Nerd Fonts font (a patched "Nerd Font Mono" as primary, or the symbols font via add_regular_fonts). A symbol (Private Use Area, or an ambiguous-width char its font draws wider than a cell) is fitted by Nerd Fonts' own scaling rules. Follow an icon with a space to get the full-size two-cell icon: "\u{f07b} src". The space keeps its own cell and background; a symbol right after another symbol stays in one cell, so runs keep their alignment (the rule Ghostty uses). Fonts::set_fit_symbols(false) turns this off, before the Fonts is shared. See examples/nerd_font_icons.rs.
Color emoji need the opt-in emoji feature and a font you supply (none ships with the crate):
- Draw emoji as ordinary text: ZWJ sequences, flags, keycaps and skin tones
take the two cells ratatui reserves. U+FE0E selects the text form.
TuiFontSource::Assetbuilds a single-fontFonts, so it cannot carry an
emoji font. Pass a built Arc<Fonts> instead, on Wasm from include_bytes! or bytes you fetched.
- Without the feature, register a monochrome emoji font with
add_fonts: a
color font there gives a solid silhouette (COLR) or a blank cell (PNG).
- Fonts added here never change the cell size. Combining marks on an emoji come
from a text font, so that font must have them.
- A multi-char symbol set directly on a cell is as wide as the whole string,
clamped to two cells.
See examples/emoji.rs (--features emoji).
Styling — every ratatui Modifier renders; use plain ratatui Styles:
DIM= lower fg alpha (TerminalConfig::dim_alpha, default 128);
HIDDEN = blank cell; UNDERLINED / CROSSED_OUT = bars in the fg color.
- Opt-in features:
blink(SLOW_BLINK/RAPID_BLINK, else ignored) and
underline_color (Style::underline_color, else the fg color).
- The cursor from
frame.set_cursor_position(...)is drawn as
TerminalConfig::cursor_style (Block default, HollowBlock, Underline, Bar, Hidden).
TerminalConfig::palette:TerminalPalette::default()keeps historic
colors (DarkGray brighter than Gray), xterm() is standard, .with_ansi16([...]) themes.
- Runtime changes (
Tui::set_dim_alpha/set_palette/
set_cursor_style) redraw by themselves; no draw needed.
- Box drawing (all weights, so ratatui's thick/dashed/merged borders),
blocks, Braille, Powerline, sextants/octants (Marker::Sextant / Octant) are drawn procedurally; scrollbar/gauge symbols fall back to procedural glyphs only when no font has them.
Input, focus, and hit testing
Read MessageReader<TerminalEvent> (bevy messages, not EventReader); event.target is always the Tui entity, even for attached surfaces. The payload (event.input: InputEvent) mirrors crossterm::event::Event, not a crate-specific enum, so ratatui-ecosystem input vocabulary (tui-textarea, bevy_ratatui, etc.) maps onto it directly. Variants: Key(KeyEvent { code, modifiers, kind }), Mouse(MouseEvent { kind, column, row, modifiers }), Paste(String), FocusGained, FocusLost, Resize { pixels }. KeyCode here is this crate's crossterm-shaped mirror (bevy_tui_texture::input::KeyCode), NOT bevy::prelude::KeyCode (the physical key) — the prelude deliberately omits it (glob-import collision), so import it explicitly: use bevy_tui_texture::input::KeyCode;.
- `(column, row)` on `MouseEvent` — grid cells, x-then-y.
- `MouseEventKind`:
Down(button)/Up(button)(was
MousePress/MouseRelease), Moved (no button held) or Drag(button) (button held while moving — was a single MouseMove for both), ScrollUp/ScrollDown/ScrollLeft/ScrollRight.
- `KeyEventKind`:
Press/Repeat/Release— filter
k.kind != KeyEventKind::Release for typical "act on keydown" handling. KeyCode::Char(c) already carries the shifted/layout-resolved character (winit resolves it) — there is no separate CharInput.
- Keyboard needs focus: only the focused terminal receives
Key
events. Clicking a terminal focuses it (TerminalInputConfig::focus_button, default left); Tab cycles focus when auto_focus is on. Mouse events need no focus.
- Touch works with no extra code:
CursorPosition(the crate's
tracked resource) falls back to the first active touch's position (and the just-released touch's last position on the release frame), and a tap emits MouseEventKind::Down/Up(MouseButton::Left). In your own systems, read Res<CursorPosition> instead of Window::cursor_position() — the latter is None on touch devices (see update_camera_rotation in examples/retro_crt.rs).
- Plugin constructors:
TerminalPlugin::default()(all input),
::new(TerminalInputConfig { keyboard_enabled, mouse_enabled, auto_focus, focus_button }), ::without_keyboard(), ::without_mouse(), ::display_only().
- Multi-camera picking works out of the box: rays are built per
active camera via Camera::viewport_to_world (correct for every projection/ScalingMode), prioritized by descending Camera::order, then hit distance.
- **When a 2D UI terminal and a 3D mesh terminal overlap in screen
space, the UI terminal wins** — bevy_ui renders as an overlay on top of the 3D view, so the pick order matches what the user sees.
- `crossterm-compat` feature (native-only):
InputEvent::to_crossterm/
from_crossterm for lossy round-trips with real crossterm::event::Event — e.g. feeding a crossterm-shaped widget crate or bridging from a bevy_ratatui adapter. Not available on wasm32.
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 95 of 100Pattern checks on the skill's files.
- Model reviewPassed
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingSKILL.md
Popularity
- Repository stars
- 64
- Repository forks
- 1
- 7-day trend score
- 21
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/tt-toe/bevy_tui_texture/bevy-tui-texture)