Files
pages-tui/REDESIGN.md

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 focusable
  • handle() - What happens on action
  • on_enter/on_exit - Lifecycle hooks
  • on_focus/on_blur - Focus lifecycle
  • handle_text() - Optional text input
  • can_navigate_*() - Optional boundary detection

Orchestrator - The runtime:

  • register_page() - Add pages
  • navigate_to() - Page navigation
  • process_frame() - Process one input frame
  • run() - Complete main loop

Standard actions - Common patterns:

  • Next, Prev, First, Last - Navigation
  • Select, Cancel - Selection
  • TypeChar, Backspace, Delete - Text input
  • Custom(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's Orchestrator
  • ActionDecider routing logic - Uses library's event handler
  • Manual lifecycle calls - Uses library's automatic hooks
  • Mode stack assembly - Uses library's ModeResolver extension
  • Overlay management - Uses library's OverlayManager extension

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

  1. Additional lifecycle hooks: Add new methods to Component trait with default impls
  2. More actions: Add variants to ComponentAction enum
  3. New overlay types: Implement OverlayManager trait
  4. Custom input sources: Implement InputSource trait
  5. Animation support: Add hooks for frame updates
  6. Accessibility: Add hooks for screen readers

What Requires Breaking Changes

  1. Component trait signature: Changing associated types
  2. Orchestrator API: Major method signature changes
  3. Extension point contracts: Changing trait methods

Strategy: Mark APIs as #[doc(hidden)] or #[deprecated] before removing.


Summary

The redesigned TUI Orchestrator is:

  1. Complete framework - Not just building blocks
  2. Zero boilerplate - Users define components, library runs show
  3. Sensible defaults - Works without configuration
  4. Fully extendable - Trait-based extension points
  5. komp_ac compatible - Can replace existing orchestration
  6. 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.