14 KiB
TUI Orchestrator Integration Guide
This guide shows how to use the TUI Orchestrator framework to build terminal user interfaces with minimal boilerplate.
Quick Start: Your First TUI App
Step 1: Define Your Component
A component represents a page or UI section with focusable elements:
extern crate alloc;
use tui_orchestrator::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum LoginFocus {
Username,
Password,
LoginButton,
CancelButton,
}
#[derive(Debug, Clone)]
enum LoginEvent {
AttemptLogin { username: String, password: String },
Cancel,
}
struct LoginPage {
username: alloc::string::String,
password: alloc::string::String,
}
impl Component for LoginPage {
type Focus = LoginFocus;
type Action = ComponentAction;
type Event = LoginEvent;
fn targets(&self) -> &[Self::Focus] {
&[
LoginFocus::Username,
LoginFocus::Password,
LoginFocus::LoginButton,
LoginFocus::CancelButton,
]
}
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> {
match (focus, action) {
(LoginFocus::LoginButton, ComponentAction::Select) => {
Ok(Some(LoginEvent::AttemptLogin {
username: self.username.clone(),
password: self.password.clone(),
}))
}
(LoginFocus::CancelButton, ComponentAction::Select) => {
Ok(Some(LoginEvent::Cancel))
}
_ => Ok(None),
}
}
fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result<Option<Self::Event>> {
match focus {
LoginFocus::Username => {
self.username.push(ch);
Ok(None)
}
LoginFocus::Password => {
self.password.push(ch);
Ok(None)
}
_ => Ok(None),
}
}
fn on_enter(&mut self) -> Result<()> {
self.username.clear();
self.password.clear();
Ok(())
}
}
Step 2: Register and Run
use tui_orchestrator::prelude::*;
fn main() -> Result<()> {
let mut orch = Orchestrator::builder()
.with_page("login", LoginPage::new())
.with_default_bindings()
.build()?;
orch.navigate_to("login")?;
orch.run(&mut MyInputSource)?;
}
That's it. The library handles:
- Input processing
- Focus management (Tab/Shift+Tab navigation)
- Button activation (Enter key)
- Text input typing
- Page lifecycle (on_enter/on_exit)
Component Trait Deep Dive
Associated Types
pub trait Component {
type Focus: FocusId + Clone; // What can be focused in this component
type Action: Action + Clone; // What actions this component handles
type Event: Clone + Debug; // Events this component emits
}
Required Methods
targets(&self) -> &[Self::Focus]
Returns all focusable elements. Order determines navigation sequence (next/prev).
fn targets(&self) -> &[Self::Focus] {
&[
Focus::Username,
Focus::Password,
Focus::LoginButton,
Focus::CancelButton,
]
}
handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>>
Called when a bound action occurs. Returns optional event for application to handle.
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> {
match (focus, action) {
(Focus::Submit, ComponentAction::Select) => Ok(Some(Event::Submit)),
_ => Ok(None),
}
}
Optional Methods
All have default implementations—only override what you need.
on_enter(&mut self) -> Result<()>
Called when component becomes active (page is navigated to). Good for resetting state.
on_exit(&mut self) -> Result<()>
Called when component becomes inactive (page is navigated away). Good for cleanup.
on_focus(&mut self, focus: &Self::Focus) -> Result<()>
Called when a specific focus target gains focus.
on_blur(&mut self, focus: &Self::Focus) -> Result<()>
Called when a specific focus target loses focus.
handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result<Option<Self::Event>>
Called when character is typed (not a bound action). Only called for text-friendly focus targets.
can_navigate_forward(&self, focus: &Self::Focus) -> bool
Return false to prevent Next action from moving focus (useful for boundary detection).
can_navigate_backward(&self, focus: &Self::Focus) -> bool
Return false to prevent Prev action from moving focus.
Standard Component Actions
The library provides these actions automatically bound to keys:
| Action | Default Key | Description |
|---|---|---|
ComponentAction::Next |
Tab | Move focus to next target |
ComponentAction::Prev |
Shift+Tab | Move focus to previous target |
ComponentAction::First |
Home | Move focus to first target |
ComponentAction::Last |
End | Move focus to last target |
ComponentAction::Select |
Enter | Activate current focus target |
ComponentAction::Cancel |
Esc | Cancel or close |
ComponentAction::TypeChar(c) |
Any character | Type character |
ComponentAction::Backspace |
Backspace | Delete character before cursor |
ComponentAction::Delete |
Delete | Delete character at cursor |
ComponentAction::Custom(n) |
None | User-defined action |
Customizing Bindings
let mut orch = Orchestrator::new();
// Override default bindings
orch.bindings().bind(Key::ctrl('s'), ComponentAction::Custom(0)); // Custom save action
orch.bindings().bind(Key::char(':'), ComponentAction::Custom(1)); // Enter command mode
Orchestrator API
Basic Setup
let mut orch = Orchestrator::new();
// Register pages
orch.register_page("login", LoginPage::new())?;
orch.register_page("home", HomePage::new())?;
// Navigation
orch.navigate_to("login")?;
Processing Input
loop {
let key = read_key()?;
let events = orch.process_frame(key)?;
for event in events {
match event {
LoginEvent::AttemptLogin => do_login(username, password),
LoginEvent::Cancel => return Ok(()),
}
}
render(&orch)?;
}
Reading State
// Get current page
if let Some(page) = orch.current_page() {
// Access page...
}
// Get current focus
if let Some(focus) = orch.focus().current() {
// Check what's focused...
}
// Create query snapshot
let query = orch.focus().query();
if query.is_focused(&LoginFocus::Username) {
// Username field is focused...
}
Multiple Pages Example
#[derive(Debug, Clone)]
enum MyPage {
Login(LoginPage),
Home(HomePage),
Settings(SettingsPage),
}
fn main() -> Result<()> {
let mut orch = Orchestrator::new();
orch.register_page("login", LoginPage::new())?;
orch.register_page("home", HomePage::new())?;
orch.register_page("settings", SettingsPage::new())?;
orch.navigate_to("login")?;
orch.run()?;
}
Navigation with history:
orch.navigate_to("home")?;
orch.navigate_to("settings")?;
orch.back()? // Return to home
Extension: Custom Mode Resolution
For apps with complex mode systems (like komp_ac):
pub struct CustomModeResolver {
state: AppState,
}
impl ModeResolver for CustomModeResolver {
fn resolve(&self, focus: &dyn Any) -> alloc::vec::Vec<ModeName> {
match focus.downcast_ref::<FocusTarget>() {
Some(FocusTarget::CanvasField(_)) => {
// Dynamic mode based on editor state
vec![self.state.editor_mode(), ModeName::Common, ModeName::Global]
}
_ => vec![ModeName::General, ModeName::Global],
}
}
}
let mut orch = Orchestrator::new();
orch.set_mode_resolver(CustomModeResolver::new(state));
Extension: Custom Overlays
For apps with complex overlay types (command palette, dialogs):
pub struct CustomOverlayManager {
command_palette: CommandPalette,
dialogs: Vec<Dialog>,
}
impl OverlayManager for CustomOverlayManager {
fn is_active(&self) -> bool {
self.command_palette.is_active() || !self.dialogs.is_empty()
}
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> {
if let Some(result) = self.command_palette.handle_input(key) {
return Some(result);
}
// Handle dialogs...
None
}
}
let mut orch = Orchestrator::new();
orch.set_overlay_manager(CustomOverlayManager::new());
Integration with External Libraries
Reading Input from crossterm
use crossterm::event;
use tui_orchestrator::input::Key;
impl InputSource for CrosstermInput {
fn read_key(&mut self) -> Result<Key> {
match event::read()? {
event::Event::Key(key_event) => {
let code = match key_event.code {
event::KeyCode::Char(c) => KeyCode::Char(c),
event::KeyCode::Enter => KeyCode::Enter,
event::KeyCode::Tab => KeyCode::Tab,
event::KeyCode::Esc => KeyCode::Esc,
// ... map all codes ...
};
let modifiers = KeyModifiers {
control: key_event.modifiers.contains(event::KeyModifiers::CONTROL),
alt: key_event.modifiers.contains(event::KeyModifiers::ALT),
shift: key_event.modifiers.contains(event::KeyModifiers::SHIFT),
};
Ok(Key::new(code, modifiers))
}
_ => Err(Error::NotAKeyEvent),
}
}
}
Using with ratatui for Rendering
The library is backend-agnostic—you can render with any framework:
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tui_orchestrator::prelude::*;
struct MyApp {
orch: Orchestrator<...>,
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}
impl MyApp {
fn render(&mut self) -> Result<()> {
self.terminal.draw(|f| {
let focus = self.orch.focus().query();
let page = self.orch.current_page().unwrap();
// Render page with focus context
page.render(f, &focus);
})?;
}
fn run(&mut self) -> Result<()> {
loop {
let key = self.orch.read_key()?;
let events = self.orch.process_frame(key)?;
for event in events {
self.handle_event(event)?;
}
self.render()?;
}
}
}
Testing Components
Unit Tests
Test component logic in isolation:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_login_button_action() {
let mut page = LoginPage::new();
let focus = LoginFocus::LoginButton;
let action = ComponentAction::Select;
let event = page.handle(&focus, action).unwrap();
assert!(matches!(event, Some(LoginEvent::AttemptLogin { .. })));
}
}
Integration Tests
Test with orchestrator:
#[test]
fn test_full_login_flow() {
let mut orch = Orchestrator::new();
orch.register_page("login", LoginPage::new()).unwrap();
// Simulate tab navigation
let _ = orch.process_frame(Key::tab()).unwrap();
assert_eq!(orch.focus().current(), Some(&LoginFocus::Password));
// Simulate typing
let _ = orch.process_frame(Key::char('p')).unwrap();
let _ = orch.process_frame(Key::char('a')).unwrap();
let _ = orch.process_frame(Key::char('s')).unwrap();
let _ = orch.process_frame(Key::char('s')).unwrap();
// Simulate enter
let events = orch.process_frame(Key::enter()).unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], LoginEvent::AttemptLogin { .. }));
}
Migration from Existing Code
Migrating from Manual Wiring
Before:
// Manual setup
let mut focus = FocusManager::new();
let mut bindings = Bindings::new();
let mut router = Router::new();
let mut page = LoginPage::new();
focus.set_targets(page.targets());
bindings.bind(Key::tab(), MyAction::Next);
// Manual loop
loop {
let key = read_key()?;
if let Some(action) = bindings.handle(key) {
match action {
MyAction::Next => focus.next(),
MyAction::Select => {
let focused = focus.current()?;
page.handle_button(focused)?;
}
}
}
}
After:
// Framework setup
let mut orch = Orchestrator::builder()
.with_page("login", LoginPage::new())
.build()?;
orch.run()?;
Keeping Custom Behavior
If your existing code has custom behavior (like komp_ac's mode resolution), use extension points:
let mut orch = Orchestrator::new()
.with_mode_resolver(CustomModeResolver::new(state))
.with_overlay_manager(CustomOverlayManager::new())
.with_event_handler(CustomEventHandler::new(router));
Best Practices
1. Keep Components Focused
Components should handle their own logic only. Don't directly manipulate other components.
2. Use Events for Communication
Components should emit events, not directly call methods on other components.
3. Respect Optional Methods
Only override lifecycle hooks when you need them. Default implementations are fine for most cases.
4. Test Component Isolation
Test components without orchestrator to ensure logic is correct.
5. Leverage Default Bindings
Use with_default_bindings() unless you have specific keybinding requirements.
6. Use Extension Points Wisely
Only implement custom resolvers/handlers when default behavior doesn't meet your needs.
Summary
The TUI Orchestrator framework provides:
- Zero boilerplate - Define components, library handles the rest
- Sensible defaults - Works without configuration
- Full extension - Customize via traits when needed
- Backend-agnostic - Works with any rendering library
- no_std compatible - Runs on embedded systems and WASM
Your job: define components with buttons and logic. Our job: make it just work.