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.