Introduction #
Most terminal UI libraries model a screen: a tree of components that the framework re-renders and reconciles as state changes. That model fits full-screen apps. It fits inline apps — the kind that share the terminal with your shell — badly, because an inline app's output isn't a screen at all. It's a timeline: an append-only sequence of finished output, with a small live region at the bottom that's still changing.
eye-declare is built on one observation about that shape:
Committed output is an effect. The live tail is a view.
Writing a block of finished output toward scrollback is I/O — irreversible
and append-only, like println!. So in eye-declare, emitting a block happens
in your update function, as an effect (ctx.push). The live tail is the only
thing that behaves like a screen, so it's the only thing described by a view
function — re-run every frame, pure, and cheap because it's small.
What this dissolves #
Because blocks render exactly once and the tail re-renders wholesale, whole categories of framework machinery have nothing to do and don't exist here:
- No reconciliation, no keys. There is no retained tree to diff your
elements against.
col().children(items.iter().map(row_view))needs no identity annotations because nothing is ever matched up across frames. - No dirty tracking. The tail is rebuilt every frame unconditionally. Identical tails diff to zero bytes at the terminal layer; that's the optimization, and it needs nothing from you.
- No framework-owned widget state. Your model — the struct implementing
App— is the only state. Widget state (a text area's contents, a select's cursor) is a plain field you own and mutate inupdate. - No hidden focus registry. Focus is a value in your model
(
FocusHandle), and keys resolve to messages through aKeymapyou rebuild from the model each update. What a key does is always derivable from your state, never from what the framework last focused.
The shape of an app #
If you've written Elm, iced, or Redux, this is that architecture with one addition — the timeline:
terminal events ─┐
messages from tasks ─┼─▶ update(&mut model, msg, ctx) ─▶ ctx.push(block) effect: permanent output
subscription ticks ─┘ ctx.spawn(stream) effect: async work
│
▼
tail(&model) ─▶ diff ─▶ terminal view: the live region
The model is a plain struct. update takes &mut self; tail takes
&self. The borrow checker enforces the discipline for free.
Inline first, fullscreen too #
The timeline is the reason eye-declare exists, and inline is its native
mode. But the same app can take over the whole terminal:
ScreenMode::AltScreen runs it on the alternate screen, with the tail as
the entire screen — same model, same update, same tail, sized to the
terminal via App::on_resize. An app can even choose per invocation, the
way a shell-history search runs inline at a configured height but goes
fullscreen on a short terminal. The
runtime reference
covers what changes — mostly that scrollback, and with it ctx.push,
stops meaning anything.
What eye-declare still isn't: a component-tree framework. It's built on
Ratatui's primitives and hands you its Buffer and
Style types — Ratatui widgets render directly into elements — but state
lives in your model, not in a retained tree, in either screen mode.
Continue to installation, or read the timeline for the semantics that follow from the design.