Rust TUI Architecture Guide
From Layout Patterns to Production Implementation
Last Updated: 2025-10-17 Context: Synthesized from terminal UI explorations, Angular ui-router patterns, Vue component systems, and ratatui experiments
Overview: Why TUI Layout Architecture Matters
Terminal UIs aren’t just “simple GUIs” - they operate under different constraints and affordances:
- Character-based grids (not pixels) as the rendering primitive
- Terminal resize events require dynamic layout recalculation
- Keyboard-first interaction demands thoughtful focus management
- ANSI/Unicode rendering provides surprising visual richness
- Cross-platform inconsistencies in terminal capabilities
The architecture you choose determines:
- How responsive your UI feels during terminal resize
- How easily you can hot-reload layouts during development
- Whether you can let users customize the interface
- How testable your UI logic becomes
- What mental model developers need to understand your code
Foundational Concepts
Terminal Coordinate Systems
Terminals are fundamentally 2D character grids:
// Terminal grid: (0,0) at top-left
// Each cell holds one character + style attributes
struct Cell {
character: char,
fg_color: Color,
bg_color: Color,
modifiers: Modifiers, // bold, italic, underline, etc.
}
type TerminalGrid = Vec<Vec<Cell>>;
Coordinate mapping:
// Metal NDC (-1 to 1) requires conversion for low-level rendering
fn terminal_to_ndc(row: u16, col: u16, grid_size: (u16, u16)) -> (f32, f32) {
let x = (col as f32 / grid_size.1 as f32) * 2.0 - 1.0;
let y = 1.0 - (row as f32 / grid_size.0 as f32) * 2.0; // Y-flip for top-left origin
(x, y)
}
Layout Constraints vs. Absolute Positioning
Absolute positioning:
// Direct control, breaks on resize
render_widget(widget, Rect::new(10, 5, 50, 20));
Constraint-based:
// Declares intent, adapts to available space
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Fixed: header
Constraint::Min(10), // Flexible: content
Constraint::Percentage(20), // Proportional: sidebar
])
.split(area);
Trade-off: Absolute = precise control, constraint = responsive adaptation.
State Management Patterns
Immediate mode (no persistent state):
fn render(terminal: &mut Terminal) {
terminal.draw(|f| {
// Rebuild UI from scratch every frame
if button(f, "Click") { /* action */ }
})?;
}
Retained mode (persistent widget tree):
struct App {
root: Container,
state: AppState,
}
impl App {
fn update(&mut self, event: Event) {
self.root.handle_event(event, &mut self.state);
}
fn render(&self, terminal: &mut Terminal) {
self.root.render(terminal);
}
}
Layout Paradigm Comparison
| Pattern | Mental Model | When to Use | Trade-offs |
|---|---|---|---|
| Constraint-Based | CSS Flexbox/Grid | Responsive layouts, terminal resize matters | Less precise control, learning curve |
| Immediate Mode | Game engine UI (ImGui) | Animations, simple state, rapid prototyping | Full redraw cost, no widget persistence |
| Component Tree | React/Vue SPA | Complex state, functional composition | Diffing overhead, memory for virtual tree |
| Event-Driven Widgets | GTK/Qt | Traditional GUI patterns, long-lived widgets | Boilerplate, callback spaghetti risk |
| Multiplexer | tmux/screen panes | Independent views, process isolation | IPC complexity, coarse-grained layout |
| Grid/Table | Spreadsheet cells | Precise control, terminal-native | Manual resize handling, no abstractions |
| Scene Graph | Game engine nodes | Spatial transforms, hierarchical rendering | Overkill for most TUIs |
| State Machine Router | Angular ui-router | Modal workflows, wizard-like flows | State explosion if overused |
Deep Dive: Constraint-Based (ratatui style)
Example: Master-detail layout
use ratatui::{
layout::{Constraint, Direction, Layout},
widgets::{Block, Borders, List, Paragraph},
};
fn render_master_detail(f: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // List
Constraint::Percentage(70), // Detail
])
.split(f.size());
// Render list in left pane
let items: Vec<ListItem> = state.items.iter()
.map(|item| ListItem::new(item.title.clone()))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Items"));
f.render_widget(list, chunks[0]);
// Render detail in right pane
if let Some(selected) = &state.selected_item {
let detail = Paragraph::new(selected.content.clone())
.block(Block::default().borders(Borders::ALL).title("Detail"));
f.render_widget(detail, chunks[1]);
}
}
Strengths:
- Automatic adaptation to terminal resize
- Declarative intent (not imperative positioning)
- Composable layouts via nested splits
Weaknesses:
- Cannot specify “exactly 3 rows below the header”
- Constraint solving adds minimal overhead
- Percentage splits can be unintuitive with odd terminal sizes
Deep Dive: Immediate Mode
Example: Simple button widget
fn button(f: &mut Frame, area: Rect, label: &str, focused: bool) -> bool {
let style = if focused {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
};
let button = Paragraph::new(label)
.style(style)
.block(Block::default().borders(Borders::ALL));
f.render_widget(button, area);
// Return true if button was "clicked" (Enter pressed while focused)
focused && matches!(last_event(), Event::Key(KeyCode::Enter))
}
// Usage in main loop:
loop {
terminal.draw(|f| {
let area = f.size();
if button(f, area, "Save", app.focused_on_save) {
app.save();
}
})?;
}
Strengths:
- Minimal boilerplate
- No state synchronization bugs
- Easy to reason about (function call = widget)
Weaknesses:
- Full redraw every frame (acceptable for TUIs, usually <60fps)
- Stateless (must track focus/selection externally)
- No widget-level event handlers
JSON-Driven Dynamic Components
Inspired by Vue’s :is component pattern, enable runtime UI modification without recompiling.
Enum Dispatch Pattern (Type-Safe)
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", content = "props")]
enum Component {
VStack {
spacing: u16,
children: Vec<Component>
},
HStack {
spacing: u16,
children: Vec<Component>
},
Text {
content: String,
style: TextStyle
},
Button {
label: String,
action: String
},
Input {
placeholder: String,
value: String
},
Panel {
title: String,
child: Box<Component>
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct TextStyle {
bold: bool,
italic: bool,
color: String,
}
impl Component {
fn render(&self, f: &mut Frame, area: Rect, state: &mut AppState) {
match self {
Component::VStack { spacing, children } => {
let child_height = (area.height - spacing * (children.len() as u16 - 1))
/ children.len() as u16;
let mut y = area.y;
for child in children {
let child_area = Rect::new(area.x, y, area.width, child_height);
child.render(f, child_area, state);
y += child_height + spacing;
}
}
Component::Text { content, style } => {
let mut text_style = Style::default();
if style.bold { text_style = text_style.add_modifier(Modifier::BOLD); }
if style.italic { text_style = text_style.add_modifier(Modifier::ITALIC); }
let widget = Paragraph::new(content.as_str()).style(text_style);
f.render_widget(widget, area);
}
Component::Button { label, action } => {
let widget = Paragraph::new(label.as_str())
.block(Block::default().borders(Borders::ALL));
f.render_widget(widget, area);
// Handle button action via state.pending_action = Some(action.clone())
}
// ... other component types
_ => {}
}
}
}
JSON Configuration Example
{
"type": "VStack",
"props": {
"spacing": 1,
"children": [
{
"type": "Panel",
"props": {
"title": "Terminal Grid Demo",
"child": {
"type": "Text",
"props": {
"content": "Hello from JSON-driven UI!",
"style": {
"bold": true,
"italic": false,
"color": "cyan"
}
}
}
}
},
{
"type": "HStack",
"props": {
"spacing": 2,
"children": [
{
"type": "Button",
"props": {
"label": "Save",
"action": "save_config"
}
},
{
"type": "Button",
"props": {
"label": "Cancel",
"action": "cancel"
}
}
]
}
}
]
}
}
Hot Reload Implementation
use notify::{Watcher, RecursiveMode, watcher};
use std::sync::mpsc::channel;
use std::time::Duration;
struct HotReloadApp {
config_path: PathBuf,
root_component: Component,
watcher: notify::RecommendedWatcher,
}
impl HotReloadApp {
fn new(config_path: PathBuf) -> Result<Self> {
let root_component = Self::load_config(&config_path)?;
let (tx, rx) = channel();
let mut watcher = watcher(tx, Duration::from_secs(1))?;
watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
Ok(Self {
config_path,
root_component,
watcher,
})
}
fn load_config(path: &Path) -> Result<Component> {
let json = std::fs::read_to_string(path)?;
let component: Component = serde_json::from_str(&json)?;
Ok(component)
}
fn check_reload(&mut self) -> Result<bool> {
// Non-blocking check for file changes
if let Ok(_) = self.watcher.try_recv() {
self.root_component = Self::load_config(&self.config_path)?;
return Ok(true);
}
Ok(false)
}
}
Benefits:
- Edit JSON, see changes instantly (no recompile)
- Non-technical users can modify layouts
- A/B test different layouts by swapping config files
- Save user preferences as JSON
State Machine Routing (ui-router Pattern)
Translate Angular ui-router’s state-based routing to Rust for modal workflows.
#[derive(Debug, Clone, PartialEq)]
enum AppState {
Dashboard {
selected_item: Option<usize>,
},
Settings {
active_tab: SettingsTab,
},
Editor {
file_path: PathBuf,
dirty: bool,
},
}
#[derive(Debug, Clone)]
struct Transition {
from: AppState,
to: AppState,
on_exit: Vec<Box<dyn Fn(&mut AppContext)>>,
on_enter: Vec<Box<dyn Fn(&mut AppContext)>>,
}
struct StateMachine {
current: AppState,
transitions: Vec<Transition>,
}
impl StateMachine {
fn transition_to(&mut self, next: AppState, ctx: &mut AppContext) {
// Find matching transition
if let Some(transition) = self.transitions.iter()
.find(|t| t.from == self.current && t.to == next)
{
// Execute exit hooks
for hook in &transition.on_exit {
hook(ctx);
}
// Update state
self.current = next.clone();
// Execute enter hooks
for hook in &transition.on_enter {
hook(ctx);
}
}
}
}
Named regions (like ui-router’s named views):
struct LayoutRegions {
header: Option<Region>,
sidebar: Option<Region>,
main: Region,
footer: Option<Region>,
}
// Render component into named region
fn render_in_region(component: &Component, target: &str, regions: &LayoutRegions) {
match target {
"header" => regions.header.as_ref().map(|r| component.render(r.area)),
"sidebar" => regions.sidebar.as_ref().map(|r| component.render(r.area)),
"main" => component.render(regions.main.area),
"footer" => regions.footer.as_ref().map(|r| component.render(r.area)),
_ => None,
};
}
Connection to Previous Work
October 2025: Terminal Grid Renderer
Current Status: Building Metal-based terminal renderer with:
- Grid coordinate system (row/col → pixel position)
- Cell-based text positioning
- Scene updates via
Vec<(row, col, text)> - Font scaling (128pt → 15pt) and Y-axis flipping for NDC
Integration opportunity: JSON-driven component system can target this grid renderer:
// Component renders to abstract grid
component.render_to_grid(&mut grid_state);
// Grid state translates to Metal scene updates
let updates = grid_state.calculate_updates();
metal_renderer.apply_updates(updates);
March 2025: Multi-Platform Conversation Explorer
Learned patterns:
- Master-detail layout with ratatui constraints
- Grouped conversation display (by date)
- Scrollable message lists with text wrapping
- Event-driven state updates (arrow keys, Enter to select)
Applicable here:
- Same layout pattern useful for file browsers, log viewers
- Text wrapping logic reusable across projects
- Focus management strategies (current_index, max_scroll) generalizable
October 2025: Rust TUI Explosion (Basalt/ratatui)
Key insight: “This is not metaphor. This is code.”
Consciousness technology stack:
FLOAT.dispatch (cultural layer)
↓
Conversation AST (interface semantics)
↓
Basalt TUI (Obsidian terminal WYSIWYG)
↓
ratatui (widget composition)
↓
Terminal cells (character grid)
Reusable patterns:
- Async integration patterns (tokio + ratatui)
- Canvas substrate for custom rendering
- Filesystem navigation widgets
- Custom constraint explorers
September 2025: Egui Exploration
Context shift: Moved from terminal (ratatui) to desktop GUI (egui)
Key difference:
- TUI: Character-based, terminal-bound, keyboard-first
- Egui: Pixel-based, windowed, immediate mode philosophy
Lesson: Immediate mode works for both TUI and GUI - state management simplicity transfers across rendering backends.
Implementation Roadmap
Phase 1: Minimal Viable Implementation (1 week)
Goal: Render JSON-defined layout with basic components.
Tasks:
- Define core
Componentenum (VStack, HStack, Text, Button) - Implement
Component::render()using ratatui primitives - Load JSON config from file
- Create simple event loop (redraw on input, no hot reload yet)
- Test with 2-3 example layouts
Dependencies:
[dependencies]
ratatui = "0.25"
crossterm = "0.27"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Deliverable: Binary that loads layout.json and renders it.
Phase 2: Core Features (2 weeks)
Goal: Add hot reload, event handling, and state management.
Tasks:
- Implement file watcher for hot reload (notify crate)
- Add input handling system (focus management, key bindings)
- Create action dispatcher (button clicks → app state changes)
- Add more component types (Input, List, Table, Panel)
- Build example app (simple file browser or todo list)
New dependencies:
notify = "6.0"
tokio = { version = "1", features = ["full"] }
Deliverable: Functional TUI app with editable layout.
Phase 3: Advanced Capabilities (4 weeks)
Goal: State machine routing, prop binding, and component library.
Tasks:
- Implement state machine router (AppState enum + transitions)
- Add template interpolation (
"{{user.name}}"→ actual data) - Build reusable component library (10+ widget types)
- Create visual layout editor (TUI for editing JSON)
- Write comprehensive documentation + examples
Deliverable: Production-ready framework for JSON-driven TUIs.
Recommended Resources
Crates
Core TUI:
- ratatui - Modern, actively maintained TUI framework
- crossterm - Cross-platform terminal manipulation
- termion - Alternative to crossterm (Unix-only)
State Management:
Configuration:
File Watching:
- notify - Cross-platform file watcher
Alternative TUI Frameworks:
Documentation
- Ratatui Book - Official guide and examples
- Ratatui Examples - 50+ example apps
- Charm Bubbletea - Go TUI framework (Elm architecture reference)
Example Projects
Production TUIs in Rust:
- gitui - Git TUI with ratatui
- bottom - System monitor TUI
- spotify-tui - Spotify client TUI
- diskonaut - Disk usage explorer
Architecture References:
- Obsidian - Plugin system inspiration
- VS Code - Command palette pattern
- tmux - Multiplexer architecture
Practical Examples
Example 1: Basic Enum-Based Component
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
enum SimpleComponent {
Text { content: String },
Box { title: String, child: Box<SimpleComponent> },
}
impl SimpleComponent {
fn render(&self, f: &mut Frame, area: Rect) {
match self {
SimpleComponent::Text { content } => {
let widget = Paragraph::new(content.as_str());
f.render_widget(widget, area);
}
SimpleComponent::Box { title, child } => {
let block = Block::default()
.title(title.as_str())
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
child.render(f, inner);
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load from JSON
let json = r#"
{
"Box": {
"title": "Welcome",
"child": {
"Text": {
"content": "Hello from Rust TUI!"
}
}
}
}
"#;
let component: SimpleComponent = serde_json::from_str(json)?;
// Setup terminal
let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
terminal.clear()?;
// Render
terminal.draw(|f| {
component.render(f, f.size());
})?;
std::thread::sleep(std::time::Duration::from_secs(3));
Ok(())
}
Example 2: JSON Config with Hot Reload
use notify::{Watcher, RecursiveMode, watcher, DebouncedEvent};
use std::sync::mpsc::channel;
use std::time::Duration;
struct HotReloadApp {
component: SimpleComponent,
config_path: PathBuf,
}
impl HotReloadApp {
fn new(config_path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let component = Self::load_config(&config_path)?;
Ok(Self { component, config_path })
}
fn load_config(path: &Path) -> Result<SimpleComponent, Box<dyn std::error::Error>> {
let json = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&json)?)
}
fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let (tx, rx) = channel();
let mut watcher = watcher(tx, Duration::from_millis(500))?;
watcher.watch(&self.config_path, RecursiveMode::NonRecursive)?;
loop {
// Render current component
terminal.draw(|f| {
self.component.render(f, f.size());
})?;
// Check for config file changes
if let Ok(DebouncedEvent::Write(_)) = rx.try_recv() {
match Self::load_config(&self.config_path) {
Ok(new_component) => {
self.component = new_component;
terminal.clear()?;
}
Err(e) => eprintln!("Failed to reload config: {}", e),
}
}
// Handle input (q to quit)
if crossterm::event::poll(Duration::from_millis(100))? {
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
if key.code == crossterm::event::KeyCode::Char('q') {
break;
}
}
}
}
Ok(())
}
}
Example 3: State Machine Integration
#[derive(Debug, Clone, PartialEq)]
enum AppScreen {
MainMenu,
FileExplorer { current_dir: PathBuf },
Editor { file: PathBuf },
}
struct AppStateMachine {
current: AppScreen,
layout_for_screen: HashMap<AppScreen, SimpleComponent>,
}
impl AppStateMachine {
fn new() -> Self {
let mut layouts = HashMap::new();
// Define layouts for each screen
layouts.insert(
AppScreen::MainMenu,
SimpleComponent::Box {
title: "Main Menu".to_string(),
child: Box::new(SimpleComponent::Text {
content: "Press F to open file explorer".to_string(),
}),
},
);
Self {
current: AppScreen::MainMenu,
layout_for_screen: layouts,
}
}
fn handle_input(&mut self, key: crossterm::event::KeyCode) {
match (&self.current, key) {
(AppScreen::MainMenu, crossterm::event::KeyCode::Char('f')) => {
self.current = AppScreen::FileExplorer {
current_dir: std::env::current_dir().unwrap(),
};
}
(AppScreen::FileExplorer { .. }, crossterm::event::KeyCode::Esc) => {
self.current = AppScreen::MainMenu;
}
_ => {}
}
}
fn current_layout(&self) -> &SimpleComponent {
self.layout_for_screen.get(&self.current)
.expect("Layout not defined for current screen")
}
}
Decision Framework
Use this flowchart to choose your architecture:
START: What's your primary constraint?
│
├─ Need hot-reload during development?
│ └─ YES → JSON-Driven Components (enum dispatch)
│ └─ NO → Continue
│
├─ Complex state management?
│ └─ YES → Component Tree (React-like) or Event-Driven Widgets
│ └─ NO → Continue
│
├─ Animations or game-like interactions?
│ └─ YES → Immediate Mode
│ └─ NO → Continue
│
├─ Modal workflow (wizard, multi-step process)?
│ └─ YES → State Machine Routing
│ └─ NO → Continue
│
├─ Need precise control over every cell?
│ └─ YES → Grid/Table Layout (raw terminal access)
│ └─ NO → Continue
│
├─ Building standard UI (forms, lists, tables)?
│ └─ YES → Constraint-Based (ratatui recommended)
│ └─ NO → Reassess requirements
│
DEFAULT: Start with Constraint-Based (ratatui)
Add patterns incrementally as needed
Combination strategy:
- State machine for screen transitions
- Constraint-based for layouts within screens
- JSON components for user-customizable parts
- Immediate mode for loading spinners/progress bars
Anti-Patterns to Avoid
1. Over-Engineering State Management
Bad:
// Complex state with deeply nested mutations
struct AppState {
screens: HashMap<ScreenId, Screen>,
navigation_stack: Vec<ScreenId>,
undo_history: Vec<AppState>,
// ... 20 more fields
}
Good:
// Simple, flat state
struct AppState {
current_screen: Screen,
data: HashMap<String, String>,
}
Lesson: Start simple. Add structure only when complexity demands it.
2. Ignoring Terminal Resize
Bad:
// Hardcoded layout that breaks on resize
let sidebar = Rect::new(0, 0, 30, 100);
let main = Rect::new(30, 0, 50, 100);
Good:
// Responds to actual terminal size
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(f.size());
3. Blocking the UI Thread
Bad:
fn render(f: &mut Frame) {
// Blocks for 5 seconds!
let data = fetch_data_from_api();
render_data(f, data);
}
Good:
// Async fetch with loading state
enum DataState {
Loading,
Loaded(Data),
Error(String),
}
fn render(f: &mut Frame, state: &DataState) {
match state {
DataState::Loading => render_spinner(f),
DataState::Loaded(data) => render_data(f, data),
DataState::Error(msg) => render_error(f, msg),
}
}
4. Not Testing Layout Math
Bad:
// Assumed 80x24 terminal
let rows = 24;
let cols = 80;
Good:
// Test with various terminal sizes
#[cfg(test)]
mod tests {
#[test]
fn layout_works_on_small_terminal() {
let area = Rect::new(0, 0, 40, 10);
let chunks = split_layout(area);
assert!(chunks[0].width > 0);
}
}
Next Steps
Quick Wins (1-2 hours each)
-
JSON Hello World:
- Create
layout.jsonwith Text + Box components - Render with ratatui
- Verify hot reload works
- Create
-
Constraint Experiment:
- Build 3-pane layout (header, content, footer)
- Test with different terminal sizes
- Observe constraint behavior
-
Component Library:
- Implement 3 new component types (List, Table, Input)
- Test serialization round-trip (Rust → JSON → Rust)
Weekend Projects (8-16 hours)
-
File Browser TUI:
- Master-detail layout
- Directory tree in sidebar
- File preview in main pane
- Keyboard navigation
-
Terminal Dashboard:
- Multiple panels with system info
- Auto-refresh data
- JSON-configurable panel layout
-
TUI Chat Client:
- Message list (scrollable)
- Input box at bottom
- Real-time updates (async)
Long-Term Goals (1-3 months)
-
Visual Layout Editor:
- TUI app for editing layout JSON
- Drag-and-drop component arrangement
- Live preview of changes
-
Component Library:
- 20+ reusable widgets
- Published as crate
- Documentation + examples
-
Production App:
- Full-featured application (log viewer, task manager, etc.)
- Persistent state
- Comprehensive testing
- Distribution (binary releases)
Closing Thoughts
The TUI landscape in Rust is mature and production-ready. ratatui provides excellent constraint-based layouts, serde enables JSON-driven UIs, and crossterm handles cross-platform terminal quirks. The patterns discussed here—from simple enum dispatch to complex state machines—give you a flexible toolkit for any terminal UI challenge.
Start with ratatui’s constraint system, add JSON-driven components for flexibility, and introduce state machine routing only when workflows demand it. Build incrementally, test on various terminal sizes, and enjoy the rapid feedback loop of hot-reloading layouts.
The terminal isn’t a limitation—it’s a feature. Character-based rendering forces clarity, keyboard-first interaction demands thoughtful UX, and cross-platform support is built-in. Your Rust TUI can be as sophisticated as any GUI, with the added benefits of SSH-ability, scriptability, and a 40-year-old rendering substrate that just works.
Generated: 2025-10-17
Maintainer: Synthesized from conversation archaeology
License: CC0 (Public Domain)
Feedback: Update via continued exploration and pattern refinement