Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Layered TUI TEA

The domain app should not know that a terminal has focus, tabs, backspace, or a cursor. Those are host concerns. A browser input can manage focus by itself, an iOS text field has its own editing model, and a Ratatui form needs terminal state that does not belong in app::Event.

The TUI example adds a second TEA layer above the domain app:

#![allow(unused)]
fn main() {
pub enum UiEvent {
    FocusNext,
    FocusPrevious,
    EditFocused(Edit),
    Submit,
    Logout,
    IncreaseCounter,
    DecreaseCounter,
    RandomCounter,
}
}

Raw crossterm keys are translated into these normalized TUI events. The TUI app owns draft form text and focus in screen-specific modules:

#![allow(unused)]
fn main() {
struct LoginForm {
    email: String,
    password: String,
    focused: LoginField,
}
}

Counter keys are also TUI events first. input.rs does not construct app::Event; the TUI app decides how a terminal command maps to the domain.

Submitting is the boundary between UI interaction and domain intent. On the login screen, UiEvent::Submit becomes:

#![allow(unused)]
fn main() {
app::Event::Account(account::Event::StartLogin {
    email,
    password,
})
}

On the second-factor screen, it becomes:

#![allow(unused)]
fn main() {
app::Event::Account(account::Event::SubmitSecondFactor { code })
}

Focus changes and text edits do not touch the domain app. They only update the TUI model and request a TUI render.

Composition Direction

There are two valid ways to compose this example.

For simple hosts, compose the domain first:

account::App + counter::App -> app::App -> host adapter

For richer UI hosts, compose around screens:

AuthScreen(account::App + auth UI state)
CounterScreen(counter::App + counter UI state)
-> TuiApp

This chapter keeps app::App as the domain composition root. That means AuthScreen does not own account::App; it receives account::ViewModel from the domain root and translates local UI events into account::Event.

That is slightly awkward, but intentional. It keeps domain composition logic, including the “counter requires login” guard, in app::App. FFI and TUI hosts therefore share the same domain behavior.

A larger Ratatui app might choose the other direction. AuthScreen could own account::App, CounterScreen could own counter::App, and TuiApp could compose screens directly. That gives each screen a cleaner update/view API, but the root must also own cross-screen domain policy such as login guards.

As a rule of thumb: if the host mostly forwards domain events, compose the domain first. If the host has rich screen-local state and workflows, composing by screen may be more natural.

Again, it all depends on application - krucyfiks does not enforce one way as the only one available. Pragmatism over dogma.

Render Translation

The TUI layer has its own render effect:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait TuiRender: Send + Sync {
    async fn render(&self);
}
}

The composed TUI protocol contains both local TUI effects and domain effects:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum TuiEffect {
    Render(TuiRenderEffect),
}

#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum TuiAppEffect {
    Tui(TuiEffect),
    Domain(AppEffect),
}
}

The runtime builds one composed TUI handler with the same generated builder API used by app_tea:

#![allow(unused)]
fn main() {
let tui = TuiEffect::handler()
    .with_render(render.clone())
    .build()?;
let account = AccountEffect::handler()
    .with_render(render.clone())
    .build()?;
let counter = CounterEffect::handler()
    .with_render(render.clone())
    .with_random(RandomHandler)
    .build()?;
let domain_effects = AppEffect::handler()
    .with_account(account)
    .with_counter(counter)
    .build()?;
let root = TuiAppEffect::handler()
    .with_tui(tui)
    .with_domain(domain_effects)
    .build()?;
}

The nested domain app still expects an EffectHandler<AppEffect>, while the TUI app wants a local TuiRender. Generated branch and scope adapters hide both casts:

#![allow(unused)]
fn main() {
let root = Arc::new(root);
let domain = TeaApp::new(Arc::new(TuiAppEffect::domain_handler(root.clone())));
let tui_render: Arc<dyn TuiRender> = Arc::new(TuiAppEffect::tui_scope(root));
}

RenderSignal implements both app::Render and TuiRender, so account render, counter render, and local TUI render all mark the same dirty flag without a hand-written root router. That keeps the meanings separate:

  • domain render means the domain view changed;
  • TUI render means the composed terminal view changed;
  • the terminal host still decides when to call view() and draw.

This is the same idea as the dispatch queue in the Ratatui chapter. Effects are not just forwarded outward. An adapter can translate, batch, suppress, or enrich effects before they reach the host.

Native Contrast

app_native_tui uses the same real form fields, focus, and submit translation, but it coordinates them manually next to the direct app calls. That is a valid choice for small programs. The TEA TUI version gives the terminal concerns a named event/model/effect boundary, which becomes easier to reason about as the form grows.