altui 0.2.0

A state-driven TUI runtime built on top of altui-core
Documentation

Alt TUI Project

A Rust framework for building rich terminal user interfaces and dashboards, built on top of altui-core (fork of tui-rs).

The initial goal was to let developers focus on the logic of their TUI application, rather than building an architecture from scratch.

The result, I hope, is a framework with a clean, simple architecture that makes application development easier than what's currently possible with Ratatui.

Get Started

altui

altui is a state-driven TUI runtime built around [View].

It separates a terminal application into explicit parts:

  • global application state via [AppHandler]
  • a stable collection of views via [ViewFactory]
  • per-frame area assignment via [CtxStore]
  • the event/render runtime via [ViewLoop]

The central idea is simple: every visible behavior in the UI should be explainable in terms of view state, layout assignment, and one shared app state.

Mental Model

A [View] is a state-aware UI unit that can:

  • react to events through [View::on_event]
  • update itself through [View::logic]
  • render into a buffer through [View::render]

A frame in altui is built from two coordinated closures:

  1. the views closure inserts views into [ViewFactory]
  2. the areas closure assigns [Rect] values to those views through [CtxStore]

These two sequences must stay in sync.

Core Rule: View Count Must Match Area Count

The runtime relies on one invariant:

  • the number of inserted views must always match the number of assigned areas

In practice this means:

  • the views closure defines the order of views
  • the areas closure runs every frame and must assign areas in the same order
  • even hidden views still need an area, often via [EMPTY_AREA] or [CtxStore::skip_areas]

This is why the simple_pages example can hide whole pages while still keeping the runtime stable.

View States

A view can be in one of three runtime states:

  • inactive
  • hovered
  • active

A view context can also be configured as:

  • visible or hidden
  • selectable
  • interactive
  • button-like

These flags influence navigation and event routing.

The most useful mental categories are:

  • regular view: rendered only, not part of navigation
  • selectable view: participates in hover navigation and may perform an extra action when it briefly becomes active
  • interactive view: remains active and keeps receiving input until it exits the active state
  • button view: becomes active for one iteration and then returns to hover automatically

A subtle but important detail:

  • both selectable and interactive views can receive [View::on_event]
  • a selectable view does not take over the whole input flow; it can add behavior on top of normal navigation
  • an interactive view captures input while active, so you usually need to exit active before moving to another interactive view

This distinction shows up clearly in the examples:

Read the Examples First

The root examples are the main source of truth for how altui is intended to be used.

Recommended reading order:

The README files in those example directories summarize the main behavior, but the source files are the most important reference.

Minimal Example

use altui::{
    backend::Backend,
    buffer::Buffer,
    layout::{Alignment, Constraint, Flex, Layout, Rect},
    widgets::{Paragraph, Widget},
    AltuiInit, AppHandler, AreaCache, CtxStore, Terminal, View, ViewFactory, ViewLoop,
};
use crossterm::event::{read, Event, KeyCode};

struct HelloView<'a> {
    text: Paragraph<'a>,
}

impl<'a> HelloView<'a> {
    fn new() -> Self {
        let mut text = Paragraph::new("Hello world!");
        text.alignment(Alignment::Center);
        Self { text }
    }
}

impl View for HelloView<'_> {
    type State = MainState;

    fn render(&mut self, area: Rect, _: &mut AreaCache, buf: &mut Buffer) {
        self.text.render(area, buf);
    }
}

struct MainState {}

impl AppHandler for MainState {}

impl MainState {
    fn new() -> Self {
        Self {}
    }

    fn run<'a, B: Backend>(mut self, terminal: &mut Terminal<B>) {
        ViewLoop::new(terminal).unwrap().basic_navigation(true).run(
            &mut self,
            |factory: &mut ViewFactory<MainState>, _: &mut MainState| {
                factory.insert_view(HelloView::new());
            },
            |area: Rect, ctx: &mut CtxStore, _: &mut MainState| {
                let layout = ctx.split(
                    Layout::vertical(&[Constraint::Length(1)]).flex(Flex::Center),
                    area,
                );
                ctx.next_areas(&layout);
            },
            read,
            |state, factory| {
                if let Some(Event::Key(key)) = factory.event() {
                    match key.code {
                        KeyCode::Esc | KeyCode::Char('q') => state.stop(),
                        _ => {}
                    }
                }
            },
        );
    }
}

fn main() -> std::io::Result<()> {
    AltuiInit::new(true)?.set_panic_hook().run(|terminal| {
        MainState::new().run(terminal);
        Ok(())
    })
}

This is the smallest useful altui program:

  • one app state
  • one custom view
  • one area assignment
  • one global event handler

Architecture Overview

altui follows a state-driven view architecture:

┌──────────────────────────────┐
│        Application State     │
│        (AppHandler)          │
└──────────────┬───────────────┘
               │
               │ shared mutable state
               ▼
┌──────────────────────────────┐
│           ViewLoop           │
│  (runtime event/render loop) │
└──────────────┬───────────────┘
               │
       manages │ views + contexts
               ▼
┌──────────────────────────────┐
│         ViewFactory          │
│ stores views and contexts    │
└──────────────┬───────────────┘
               │
               │ executes
               ▼
       ┌───────────────┐
       │     View      │
       │  (UI element) │
       └───────────────┘
               │
               │ renders using
               ▼
       ┌───────────────┐
       │   altui_core  │
       │ widgets/buffer│
       └───────────────┘

In this architecture:

  • [AppHandler] owns global application state and controls process lifetime
  • [ViewLoop] orchestrates events, logic, area assignment, and rendering
  • [ViewFactory] stores views and runtime contexts
  • [View] defines local UI behavior

Views do not own the app state directly. Instead, they receive mutable access to it during logic and event processing.

Event Lifecycle

A frame processed by [ViewLoop] follows this sequence:

1. Read input event
2. Run built-in navigation, if enabled
3. Dispatch the event
4. Run view logic
5. Assign areas
6. Render visible views

Event dispatch priority is:

  1. active view
  2. hovered view
  3. global event handler

This priority is why popups, editors, and mouse-driven widgets can temporarily take over input handling without replacing the whole runtime.

View vs AnyView

There are two common ways to insert UI elements into altui.

Implement View

Implement [View] when you need:

  • internal state inside the view
  • custom event handling
  • multiple child widgets rendered from one view
  • explicit hover/active behavior

Typical cases:

struct MyView { /* internal state */ }

impl View for MyView {
    type State = AppState;

    fn logic(&mut self, ctx: &mut ViewCtx, state: &mut AppState) {
        // update local state from app state
    }

    fn on_event(&mut self, event: Event, ctx: &mut ViewCtx, state: &mut AppState) {
        // optional custom input handling
    }

    fn render(&mut self, area: Rect, cache: &mut AreaCache, buf: &mut Buffer) {
        // render the view
    }
}
Use AnyView

[AnyView] is the lightest way to insert a widget into the runtime.

It is a good fit when:

  • the widget is mostly presentational
  • state already lives in the application
  • a small logic closure is enough
let paragraph = Paragraph::new("Hello world!");
factory.insert_view(AnyView::simple(paragraph));

AnyView::new is useful when the widget still needs a small amount of state-driven logic:

enum Pages {
    PageOne,
    PageTwo,
}

let paragraph = Paragraph::new("Hello world!");
factory.insert_view(AnyView::new(
    paragraph,
    |_widget, ctx, state: &mut MainState| match state.pages {
        Pages::PageOne => ctx.set_visible(false),
        Pages::PageTwo => ctx.set_visible(true),
    },
));

This pattern is used heavily in:

Layout Guidance

In area-building code, prefer [CtxStore::split] and [CtxStore::split_ext] over calling Layout::split directly.

CtxStore owns the frame-level layout cache, and the examples in this repository are written around that rule.

In render code, use [AreaCache] when a single view needs its own cached internal layout.

Reexports

altui reexports everything from altui_core.

It also reexports altui-textarea, an adapted fork of tui-textarea, from:

The fork keeps the tui-textarea API shape, but is published separately so it is clear that it is not the upstream crate.

That fork was adjusted specifically to work better with altui. It may later be proposed upstream, but for now it should be treated as part of this repository's current architecture.

Recommendations

Use ratatui if:

  • you want a mature widget ecosystem
  • you need a more stable public API
  • you prefer ecosystem familiarity over experimentation

Use altui if:

  • you want explicit control over view state and event routing
  • you want cached layout assignment built into the runtime
  • you are comfortable with an experimental API that is still being shaped by examples