Ratatui
The Ratatui integration is a host loop around the TEA app. It dispatches
events into a worker, listens for render requests, and decides when to read
view().
At startup, create a direct effect handler and pass it to the TEA wrapper:
#![allow(unused)]
fn main() {
let render = RenderSignal::new();
let counter = CounterEffect::handler()
.with_render(render.clone())
.with_random(RandomHandler)
.build()?;
let account = AccountEffect::handler()
.with_render(render.clone())
.build()?;
let effects = AppEffect::handler()
.with_account(account)
.with_counter(counter)
.build()?;
let effect_handler = HookedEffectHandler::new(effects, TracingSpanHook);
let app = Arc::new(app_tea::TeaApp::new(Arc::new(effect_handler)));
}
The terminal loop does not call block_on(update). It keeps the current view,
redraws when the render signal is dirty, and dispatches input events without
waiting for the update to finish:
#![allow(unused)]
fn main() {
let dispatcher = Dispatcher::unbounded(&runtime, Arc::clone(&app), TracingEventHook);
let mut view = app.view();
loop {
if render.take_dirty() {
view = app.view();
terminal.draw(|frame| ui::draw(frame, view.clone()))?;
}
if event::poll(Duration::from_millis(200))? {
let TerminalEvent::Key(key) = event::read()? else {
continue;
};
match input::map_key(key.code, &view) {
Input::Quit => break,
Input::Update(event) => dispatcher.dispatch(event),
Input::Ignore => {}
}
}
}
}
dispatch sends the event over a flume channel to one async worker. The
worker preserves event ordering by awaiting each update before receiving the
next event:
#![allow(unused)]
fn main() {
while let Ok(event) = rx.recv_async().await {
hook
.process(event, |event| {
let app = Arc::clone(&app);
async move {
app.update(event).await;
}
})
.await;
}
}
This is a deliberate queue. If the user quickly presses r, +, and -,
the random update starts first and the later counter events wait behind it.
The terminal still accepts input, but app state changes remain serialized.
That is not the only valid choice. An app could spawn every update
independently, and then + and - might finish before a slow random effect.
The user could see the counter move to 1, then 0, then finally 7 when
random completes. That may be right for some applications, but it means input
order and state update order can diverge. The TUI examples choose serialization
because it keeps the app state machine predictable.
A terminal can keep handlers small. RandomHandler implements the original
Random capability, and the generated builder adapts it into the composed
AppEffect protocol:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl Random for RandomHandler {
async fn get_number(&self) -> i64 {
tokio::time::sleep(Duration::from_secs(5)).await;
7
}
}
}
The five-second delay is intentional. Pressing random should not freeze terminal input or redraw scheduling; it should only leave that app update pending in the worker.
You may think - but hey why do we need to jump thru those hoops if we go back to the square one?
We do Random trait -> AppEffect -> Random trait all for nothing?
And the honest question is - to some degree you can think of it - yes.
But that’s also the point of krucyfiks. If you don’t need TEA. Then… You are free to skip it! See how we have app_tea_tui but also app_native_tui? You can pick your tool based on a job you need to do.
RenderSignal wires RenderEffect through the normal with_render(...)
builder method, but it does not draw directly. It marks the view dirty. The
terminal loop owns actual drawing through terminal.draw(...), so it can
coalesce or debounce frequent render requests and call view() separately.
Tracing hooks are optional. They wrap the boundary so effects and events can be observed, but it is not responsible for composing the app.
The next chapter adds one more layer: terminal-local TEA state for editable forms, focus, and render invalidation that is separate from the domain app.