Imperative API #

For situations where you need synchronous, direct control over the render loop, eye-declare provides InlineRenderer. This is a lower-level API than Application — you manage the event loop, timing, and stdout yourself.

When to use InlineRenderer #

  • Synchronous applications (no async runtime)
  • Embedding eye-declare in an existing event loop
  • Fine-grained control over render timing
  • Testing and debugging

Basic usage #

use std::io::{self, Write};
use eye_declare::{InlineRenderer, Spinner, TextBlock};
fn main() -> io::Result<()> {
let (width, _) = crossterm::terminal::size()?;
let mut renderer = InlineRenderer::new(width);
let mut stdout = io::stdout();
// Push components into the renderer
let spinner_id = renderer.push(Spinner::new("Loading..."));
// Render and write to stdout
let output = renderer.render();
stdout.write_all(&output)?;
stdout.flush()?;
// Animate
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(80));
renderer.tick(); // fire interval effects
let output = renderer.render();
if !output.is_empty() {
stdout.write_all(&output)?;
stdout.flush()?;
}
}
Ok(())
}

Core operations #

push #

Add a component to the renderer's root:

let id = renderer.push(Spinner::new("Working..."));
let header_id = renderer.push(TextBlock::new().line("Header", style));

Returns a NodeId for later reference.

state_mut #

Access a component's mutable state. Mutations automatically mark the component dirty:

let state = renderer.state_mut::<Spinner>(spinner_id);
state.label = "Still working...".into();

The type parameter must match the component's type State.

tick #

Fire interval effects (advance animations, etc.):

renderer.tick();

Call this regularly (e.g., every 50-80ms) to keep animations running.

render #

Produce ANSI escape sequences for the current frame:

let output: Vec<u8> = renderer.render();
stdout.write_all(&output)?;

Returns an empty Vec if nothing changed since the last render.

rebuild #

Replace a container's children with a new Elements tree:

let container = renderer.push(VStack);
// Build declarative subtrees
renderer.rebuild(container, element! {
"Hello"
Spinner(label: "Working...")
});
// Later, rebuild with new content
renderer.rebuild(container, element! {
"Done!"
});

This triggers reconciliation — matched children preserve their state.

freeze #

Mark a component as frozen. Frozen components are no longer updated or re-rendered — they remain as static content:

let header = renderer.push(TextBlock::new().line("Header", style));
let output = renderer.render();
stdout.write_all(&output)?;
renderer.freeze(header);

This is an optimization for content that won't change. Frozen content remains visible but the renderer stops tracking it.

resize #

Handle terminal resize:

Event::Resize(new_width, _) => {
let output = renderer.resize(*new_width);
stdout.write_all(&output)?;
stdout.flush()?;
}

set_focus #

Set focus to a specific component:

renderer.set_focus(input_id);

handle_event #

Deliver an event to the focused component:

renderer.handle_event(&event);

has_active #

Check if any component has active effects (intervals, etc.):

while renderer.has_active() {
renderer.tick();
let output = renderer.render();
// ...
std::thread::sleep(Duration::from_millis(50));
}

Complete example #

Here's a sync event loop from the interactive example:

fn main() -> io::Result<()> {
let (width, _) = crossterm::terminal::size()?;
let mut r = InlineRenderer::new(width);
let mut stdout = io::stdout();
// Build UI
let header = r.push(
TextBlock::new()
.line("Interactive Demo", Style::default().fg(Color::Cyan))
.unstyled(""),
);
flush(&mut r, &mut stdout)?;
r.freeze(header);
let log_id = r.push(MessageLog);
let input_id = r.push(Input);
r.set_focus(input_id);
flush(&mut r, &mut stdout)?;
// Enter raw mode for keystroke input
crossterm::terminal::enable_raw_mode()?;
loop {
if event::poll(Duration::from_millis(50))? {
let evt = event::read()?;
match &evt {
Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers,
kind: KeyEventKind::Press,
..
}) if modifiers.contains(KeyModifiers::CONTROL) => break,
Event::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => {
let text = {
let state = r.state_mut::<Input>(input_id);
let t = state.text.clone();
state.text.clear();
state.cursor = 0;
t
};
if !text.is_empty() {
r.state_mut::<MessageLog>(log_id).push(text);
}
}
Event::Resize(new_width, _) => {
let output = r.resize(*new_width);
stdout.write_all(&output)?;
stdout.flush()?;
continue;
}
_ => {
r.handle_event(&evt);
}
}
let output = r.render();
if !output.is_empty() {
stdout.write_all(&output)?;
stdout.flush()?;
}
}
}
crossterm::terminal::disable_raw_mode()?;
Ok(())
}

InlineRenderer vs Application #

InlineRendererApplication
RuntimeSynchronousAsync (Tokio)
Event loopYou manage itFramework manages it
State updatesDirect via state_mut()Via Handle::update()
Event handlingManualAutomatic or closure-based
Best forSync apps, embedding, testingMost applications