Application runtime
The urushi-tui-app crate is a runnable TEA-style full-screen application
framework. An application owns its domain choices; the runtime owns the live
model, ordered message delivery, effects, subscriptions, frame scheduling, and
the terminal session.
The application layer contains no Ratatui types. It returns the same Urushi
View used by other presentation surfaces.
Define an application
Section titled “Define an application”An application separates the program description from the model that changes while it runs.
use std::time::Duration;use urushi::{TextStyle, View};use urushi_tui_app::{Application, Effect, Subscription};
struct Counter;
enum Message { Increment,}
impl Application for Counter { type Model = i32; type Message = Message;
fn init(&self) -> (Self::Model, Effect<Self::Message>) { ( 0, Effect::after(Duration::from_millis(500), |_| Message::Increment), ) }
fn update( &self, model: &mut Self::Model, message: Self::Message, ) -> Effect<Self::Message> { match message { Message::Increment => *model += 1, } Effect::shutdown() }
fn view(&self, model: &Self::Model) -> View { View::text(model.to_string(), TextStyle::new()) }
fn subscriptions( &self, _model: &Self::Model, ) -> Subscription<Self::Message> { Subscription::none() }}Because view returns the current model as text, the complete frame before and
after an Increment message is:
01Only update receives the model mutably. Terminal input, timer ticks, effect
results, and other external events all become messages before they reach it.
This keeps application transitions directly testable without opening a
terminal.
For a complete persistent application with keyboard input, application focus, surface changes, errors, and user-driven shutdown, follow the interactive application guide.
Run the application
Section titled “Run the application”urushi_tui_app::run supplies the production executor, clock, terminal backend,
full-screen presentation, and session defaults:
fn main() -> Result<(), urushi_tui_app::Error> { let final_model = urushi_tui_app::run(Counter)?; println!("final count: {final_model}"); Ok(())}The example renders 0, delivers the delayed Increment message, updates the
model to 1, and shuts down. After restoring the terminal session, run
returns that final model and the program prints:
final count: 1The production session profile is explicit. Builder methods replace these choices before the session is entered:
| Session choice | Default | Observable consequence |
|---|---|---|
| Raw mode | On | Keys reach the application immediately instead of line-buffered input |
| Alternate screen | On | The full-screen application does not overwrite the primary shell buffer |
| Bracketed paste | On | A paste can arrive as one Input::Paste value |
| Focus reporting | On | Input::Focus can be produced by a supporting terminal |
| Keyboard enhancement | Disambiguate escape codes and report event types, when supported | KeyKind::Press, Repeat, and Release can be distinguished |
| Mouse capture | Off | Terminal text selection remains available until .mouse(true) is selected |
| Cursor hidden | On | The cursor stays hidden unless a resolved View requests a position |
An input subscription receives only events that the selected terminal and session modes can produce. In particular, handle key kind deliberately rather than assuming every terminal reports releases.
Return effects instead of performing work in update
Section titled “Return effects instead of performing work in update”An Effect<Message> describes work for the runtime to interpret. It supports
blocking work, futures, delays, latest-only keyed work, batches, and shutdown.
The completed value returns as another message, so it cannot mutate the model
out of order.
Use an effect for filesystem or network I/O and for expensive preparation that
would make input handling or view slow. Keep update focused on state
transitions and keep view cheap enough to evaluate for a frame.
This complete transition starts blocking work, then handles its result as an ordinary message:
use urushi_tui_app::Effect;
#[derive(Debug, PartialEq)]enum Message { Load, Loaded(String),}
fn update(status: &mut String, message: Message) -> Effect<Message> { match message { Message::Load => { *status = "Loading".into(); Effect::perform(|| Message::Loaded("Ready".into())) } Message::Loaded(value) => { *status = value; Effect::none() } }}
let mut status = "Idle".to_owned();let _work = update(&mut status, Message::Load);assert_eq!(status, "Loading");
// The runtime executes _work and later delivers its Loaded message.let _ = update(&mut status, Message::Loaded("Ready".into()));assert_eq!(status, "Ready");Each delivered message moves the model to the state used by the next View.
- stateIdle
- LoadstateLoading
- LoadedstateReadystatus = “Ready”
perform_latest and future_latest replace unfinished work under the same
key. after_latest does the same for a delayed message, which makes it the
debounce form. batch starts independent effects concurrently; completion
order is deliberately unspecified. Return a later effect from the update
that receives an earlier completion when ordering is required.
See Effects and subscriptions for every effect form, latest-only work, batch and shutdown behavior, and observable completion traces.
Declare ongoing sources as subscriptions
Section titled “Declare ongoing sources as subscriptions”subscriptions(&Model) returns the sources that should be active for the
current model. Public subscription constructors cover:
- terminal input;
- surface changes such as terminal dimensions;
- intervals and process signals;
- terminal draw errors; and
- custom streams, asynchronous producers, and blocking producers.
The runtime reconciles this declaration after updates. A source starts when it appears and stops when it is no longer declared. Source admission policy is explicit; the runtime does not inspect application message variants to decide which events may be replaced or backpressured.
For example, this application listens for input at all times but runs the clock
only while running is true:
use std::time::Duration;use urushi_tui_app::{Input, Subscription};
enum Message { Input(Input), Tick,}
fn subscriptions(running: bool) -> Subscription<Message> { let input = Subscription::input(Message::Input); let clock = if running { Subscription::interval( "refresh-clock", Duration::from_secs(1), |_| Message::Tick, ) } else { Subscription::none() }; Subscription::batch([input, clock])}
let _active = subscriptions(true);let _paused = subscriptions(false);| Model state | Reconciled sources | Observable result |
|---|---|---|
running = true |
input + refresh-clock |
keys and one Tick per second can reach update |
running = false |
input only | the keyed clock is stopped; no later ticks are accepted from it |
Changing an interval’s key or period replaces its identity and restarts it.
For custom producers, choose Admission::bounded(capacity) when every accepted
value must wait in FIFO order, or Admission::latest() when a newer pending
value may replace an older one.
See Effects and subscriptions for
custom producers, source identity, reconciliation, Sender, SendError, and
bounded-versus-latest backpressure.
Presentation boundary
Section titled “Presentation boundary”The runtime architecture does not make Ratatui part of Application:
The runtime resolves an application View and writes it through a borrowed
urushi_tui::Frame. The concrete urushi_tui::Screen owns working and
committed cell buffers, diffing, output commit, and failed-output recovery.
Those presentation resources do not enter the Application model.
Ratatui also remains available as an explicit adapter for applications that own their own loop. See the Ratatui adapter.
Configure the runtime
Section titled “Configure the runtime”Runtime::new(app) exposes the same blocking runtime as a builder. A run:
- create and own the live model;
- admit input, subscription events, and effect completions into one ordered delivery path;
- apply every accepted message through
update; - coalesce physical draws without skipping logical model transitions;
- resolve the latest
Viewonce for the selected terminal’s frame; and - restore the terminal session on normal and error returns, and attempt restoration from the session guard during panic unwinding.
The builder makes the physical backend, executor, clock, and session options
selectable. Presentation always uses the concrete urushi_tui::Screen; these
runtime-owned dependencies are not fields added to the application model:
use urushi_tui_app::Runtime;
fn main() -> Result<(), urushi_tui_app::Error> { let final_model = Runtime::new(Counter) .mouse(true) .bracketed_paste(false) .run()?;
assert_eq!(final_model, 1); Ok(())}Message delivery and drawing defines the ordering contract behind accepted messages, subscription reconciliation, effect starts, coalesced draws, surface changes, draw failures, and shutdown.
Add terminal images
Section titled “Add terminal images”Enable the optional graphics feature when the runtime should present Kitty or
Sixel images contained in the application’s View:
[dependencies]urushi = "0.1.0"urushi-graphics = "0.1.0"urushi-tui-app = { version = "0.1.0", features = ["graphics"] }The runtime builder selects automatic, required-protocol, or text-only behavior
with Runtime::graphics(GraphicsPreference). It then owns retained image state,
repaint, failure recovery, cleanup, and terminal restoration alongside cell
frames. Image construction remains in urushi-graphics rather than the
application crate.
On Unix, enabling graphics makes the default runtime open Urushi’s native
bidirectional terminal connection so it can positively query Kitty and Sixel
support before the input reader starts. On platforms without that native
connection, the portable default backend reports no graphics protocol evidence:
Auto keeps the text fallback, while an explicit Kitty or Sixel request
requires a custom TerminalBackend that supplies confirmed capabilities.
Configure graphics rendering and lifecycle →
What the application crate exposes
Section titled “What the application crate exposes”The public runtime surface includes:
Application,Effect, andSubscription;- input, surface, signal, admission, and sender values;
run(app)with the production defaults; andRuntime, including backend, executor, clock, and session-option replacement.
The synchronous Screen and Frame live in urushi-tui. Caller-owned
Ratatui integration lives in urushi-adapter-ratatui; it is not a runtime
backend. See the TUI overview for the complete boundary.
The TUI reference indexes every runtime, frame-engine,
and Ratatui-adapter API.