12 KiB
TUI Orchestrator: Framework-Based Design
Philosophy Shift
From Building Blocks to Framework
Old approach: Provide individual primitives (keys, bindings, focus) that users wire together manually.
New approach: Provide complete TUI framework where users define components and library handles everything else.
This is a plugin-play model:
- Library is the runtime
- Components are plugins
- Extension points allow customization
- Everything else is optional with sensible defaults
The "Ready to Use" Vision
What Users Should Do
// 1. Define component
#[derive(Debug, Clone)]
enum LoginPage {
Username,
Password,
LoginBtn,
}
#[derive(Debug, Clone)]
enum LoginEvent {
AttemptLogin { username: String, password: String },
Cancel,
}
impl Component for LoginPage {
type Focus = LoginPage;
type Action = ComponentAction;
type Event = LoginEvent;
fn targets(&self) -> &[Self::Focus] { ... }
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> { ... }
}
// 2. Register and run
fn main() -> Result<()> {
let mut orch = Orchestrator::builder()
.with_page("login", LoginPage::new())
.with_default_bindings()
.build()?;
orch.run()?;
}
What Library Does
Automatically:
- Input processing (read keys, route to actions)
- Focus management (next, prev, set, clear overlay)
- Page navigation (call on_exit, swap, call on_enter)
- Lifecycle hooks (on_focus, on_blur called at right time)
- Default bindings (Tab=Next, Enter=Select, etc.)
- Event collection and routing
Never:
- Forces user to write glue code
- Requires manual lifecycle management
- Makes assumptions about app structure
- Requires complex configuration
Extension Model
Three-Layer Architecture
Layer 1: Core Framework (Library)
├── Component trait
├── Orchestrator runtime
├── Default bindings
└── Router + lifecycle
Layer 2: Extension Points (For komp_ac)
├── ModeResolver - dynamic mode resolution
├── OverlayManager - custom overlay types
├── EventHandler - custom event routing
└── FocusNavigation - boundary detection
Layer 3: App Logic (User)
├── Page definitions
├── Business logic (gRPC, authentication)
└── Rendering
Layer 1: What Library Provides
Component trait - The abstraction:
targets()- What's focusablehandle()- What happens on actionon_enter/on_exit- Lifecycle hookson_focus/on_blur- Focus lifecyclehandle_text()- Optional text inputcan_navigate_*()- Optional boundary detection
Orchestrator - The runtime:
register_page()- Add pagesnavigate_to()- Page navigationprocess_frame()- Process one input framerun()- Complete main loop
Standard actions - Common patterns:
Next,Prev,First,Last- NavigationSelect,Cancel- SelectionTypeChar,Backspace,Delete- Text inputCustom(usize)- User extension
Layer 2: Extension Points
Each extension has a default implementation that works for simple apps, and a trait that komp_ac implements for custom behavior.
ModeResolver
Default: Static mode stack
pub struct DefaultModeResolver;
impl ModeResolver for DefaultModeResolver {
fn resolve(&self, _focus: &dyn Any) -> Vec<ModeName> {
vec![ModeName::General]
}
}
komp_ac extension: Dynamic Canvas-style mode resolution
pub struct CanvasModeResolver {
app_state: AppState,
}
impl ModeResolver for CanvasModeResolver {
fn resolve(&self, focus: &dyn Any) -> Vec<ModeName> {
// Check if focus is canvas field
// Get editor mode (Edit/ReadOnly)
// Return mode stack: [EditorMode, Common, Global]
}
}
Use case: Simple app doesn't care about modes. komp_ac needs dynamic resolution based on editor state.
OverlayManager
Default: Simple dialog/input overlay
pub struct DefaultOverlayManager {
stack: Vec<Overlay>,
}
impl OverlayManager for DefaultOverlayManager {
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> { ... }
}
komp_ac extension: Complex overlay types (command palette, find file, search palette)
pub struct KompAcOverlayManager {
command_bar: CommandBar,
find_file: FindFilePalette,
search: SearchPalette,
}
impl OverlayManager for KompAcOverlayManager {
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> {
// Route to appropriate overlay
}
}
Use case: Simple app uses built-in dialogs. komp_ac needs custom overlays that integrate with editor, gRPC, etc.
EventHandler
Default: Return events to user
pub struct DefaultEventHandler<E>;
impl<E> EventHandler for DefaultEventHandler<E> {
fn handle(&mut self, event: E) -> Result<HandleResult> {
// Just pass events back to user
Ok(HandleResult::Consumed)
}
}
komp_ac extension: Route to page/global/canvas handlers
pub struct KompAcEventHandler {
router: Router,
focus: FocusManager,
canvas_handlers: HashMap<Page, Box<dyn CanvasHandler>>,
}
impl EventHandler for KompAcEventHandler {
fn handle(&mut self, event: AppEvent) -> Result<HandleResult> {
match self.focus.current() {
Some(FocusTarget::CanvasField(_)) => self.canvas_handler.handle(event),
_ => self.page_handler.handle(event),
}
}
}
Use case: Simple app just processes events. komp_ac needs complex routing based on focus type and context.
Layer 3: App Logic
This is entirely user-defined:
- Page structs/enums
- Business logic
- API calls (gRPC, HTTP)
- State management
- Rendering
The library never touches this.
Key Design Decisions
1. Associated Types vs Generics
Choice: Component trait uses associated types
pub trait Component {
type Focus: FocusId;
type Action: Action;
type Event: Clone + Debug;
}
Why:
- One component = one configuration
- Type system ensures consistency
- Cleaner trait signature
Alternative: Generics Component<F, A, E>
Why not:
- More verbose
- Type inference harder
- Less "component feels like a thing"
2. Automatic vs Explicit Navigation
Choice: Library automatically moves focus on Next/Prev actions
Why:
- Reduces boilerplate
- Consistent behavior across apps
- Component only needs to know "button was pressed"
Alternative: Library passes Next/Prev action, component decides what to do
Why not:
- Every component implements same logic
- Easy to miss patterns
- Library already has FocusManager—use it
Escape hatch: Components can override with can_navigate_forward/backward()
3. Event Model
Choice: Components return Option<Event>, library collects and returns
Why:
- Library can handle internal events (focus changes, page nav)
- Users get clean list of events to process
- Decouples component from application
Alternative: Components emit events directly to channel/bus
Why not:
- Requires async or channels
- More complex setup
- Library can't orchestrate internal events
4. Page vs Component
Choice: Library doesn't distinguish—everything is a Component
Why:
- Simpler API
- User can nest components if needed
- Flat hierarchy, easy to understand
Alternative: Library has Page and Component concepts
Why not:
- Forces app structure
- Some apps don't have pages
- More concepts to learn
5. Extension Points
Choice: Extension points are trait objects (Box<dyn Trait> + 'static)
Why:
- Allows komp_ac to pass stateful resolvers
- Flexible at runtime
- Can be swapped dynamically
Alternative: Generic with bounds (<R: ModeResolver + Sized>)
Why not:
- Monomorphization bloat
- Can't store different implementations
- Less flexible
Comparison: Building Blocks vs Framework
Building Blocks (Old Design)
What user writes:
// Setup
let mut focus = FocusManager::new();
let mut bindings = Bindings::new();
let mut router = Router::new();
// Configuration
bindings.bind(Key::tab(), MyAction::Next);
bindings.bind(Key::enter(), MyAction::Select);
focus.set_targets(page.targets());
router.navigate(Page::Login);
// Main 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()?;
let result = page.handle_button(focused)?;
// Handle result...
}
}
}
render(&focus, &router)?;
}
Problems:
- Tons of boilerplate
- User must understand all systems
- Easy to miss lifecycle (forgot to call on_exit?)
- Manual wiring everywhere
- Every app reinvents same code
Framework (New Design)
What user writes:
impl Component for LoginPage {
fn targets(&self) -> &[Focus] { ... }
fn handle(&mut self, focus: &Focus, action: Action) -> Result<Option<Event>> { ... }
}
fn main() -> Result<()> {
let mut orch = Orchestrator::builder()
.with_page("login", LoginPage::new())
.build()?;
orch.run()?;
}
Benefits:
- Zero boilerplate
- Library handles everything
- Lifecycle automatic
- Consistent behavior
- Easy to reason about
Extension Strategy for komp_ac
What komp_ac Keeps
komp_ac continues to own:
- All page state and logic
- gRPC client and authentication
- Rendering with ratatui
- Canvas editor integration
- Command palette logic
- Find file palette logic
- Business rules
What komp_ac Replaces
komp_ac removes:
InputOrchestrator- Uses library'sOrchestratorActionDeciderrouting logic - Uses library's event handler- Manual lifecycle calls - Uses library's automatic hooks
- Mode stack assembly - Uses library's
ModeResolverextension - Overlay management - Uses library's
OverlayManagerextension
Integration Pattern
komp_ac implements Component trait for each page:
impl Component for LoginPage {
type Focus = FocusTarget;
type Action = ResolvedAction;
type Event = AppEvent;
fn targets(&self) -> &[Self::Focus] {
// Return existing focus targets
&[FocusTarget::CanvasField(0), FocusTarget::Button(0), ...]
}
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> {
// Return existing app events
match (focus, action) {
(FocusTarget::Button(0), ResolvedAction::Keybind(KeybindAction::Save)) => {
Ok(Some(AppEvent::FormSave { path: self.path.clone() }))
}
_ => Ok(None),
}
}
}
komp_ac uses extension points:
let mut orch = Orchestrator::new()
.with_mode_resolver(CanvasModeResolver::new(app_state))
.with_overlay_manager(KompAcOverlayManager::new())
.with_event_handler(KompAcEventHandler::new(router, focus));
Result: komp_ac uses library's core while keeping all custom behavior.
Future-Proofing
What Can Be Added Without Breaking Changes
- Additional lifecycle hooks: Add new methods to
Componenttrait with default impls - More actions: Add variants to
ComponentActionenum - New overlay types: Implement
OverlayManagertrait - Custom input sources: Implement
InputSourcetrait - Animation support: Add hooks for frame updates
- Accessibility: Add hooks for screen readers
What Requires Breaking Changes
- Component trait signature: Changing associated types
- Orchestrator API: Major method signature changes
- Extension point contracts: Changing trait methods
Strategy: Mark APIs as #[doc(hidden)] or #[deprecated] before removing.
Summary
The redesigned TUI Orchestrator is:
- Complete framework - Not just building blocks
- Zero boilerplate - Users define components, library runs show
- Sensible defaults - Works without configuration
- Fully extendable - Trait-based extension points
- komp_ac compatible - Can replace existing orchestration
- User-focused - "register page" not "bind chord to registry"
The library becomes a TUI runtime where users write application logic and library handles everything else.