Skip to content
UrushiDocumentation

Native Screen and Frame

Use urushi-tui when your application already owns the synchronous loop but wants Urushi to manage cell buffers, incremental drawing, cursor output, and recovery after a partial write. Screen owns presentation history. It does not own input, raw mode, the alternate screen, application state, or scheduling.

Choose Application runtime instead when the library should own message delivery, effects, subscriptions, and the terminal session. Use the Ratatui integration when the destination is a Ratatui Buffer rather than Urushi’s native terminal writer.

The default crossterm feature supplies a portable command writer. The terminal crate supplies the session, event, and query contracts used by the complete loop below.

[dependencies]
urushi = "0.1.0"
urushi-terminal = { version = "0.1.0", features = ["crossterm"] }
urushi-tui = "0.1.0"

Disable urushi-tui’s default features when supplying another urushi_terminal::CommandWriter:

urushi-tui = { version = "0.1.0", default-features = false }

This program enters and restores its own terminal session, reads events in its own loop, resolves one immutable View per frame, and copies that resolved rectangle into Frame. Press Left or Right to change the count and q to leave.

use std::{error::Error, io};
use urushi::{Align, Available, TextStyle, VerticalAlign, View, resolve};
use urushi_terminal::{
Event, EventSource, KeyCode, KeyKind, Position, SessionOptions,
TerminalQuery, TerminalSession,
backend::crossterm::CrosstermBackend,
};
use urushi_tui::{Frame, Screen};
fn main() -> Result<(), Box<dyn Error>> {
// The host owns acquisition and restoration of terminal modes.
let mut session_backend = CrosstermBackend::new(io::stdout());
let mut session = TerminalSession::enter(
&mut session_backend,
SessionOptions {
raw_mode: true,
alternate_screen: true,
hide_cursor: true,
..SessionOptions::default()
},
)?;
// Run and restoration are evaluated separately so an application error
// never skips explicit restoration.
let run_result = run_loop();
let restore_result = session.restore();
run_result?;
restore_result?;
Ok(())
}
fn run_loop() -> Result<(), Box<dyn Error>> {
// Input and output are host-owned handles for the same physical terminal.
let mut input = CrosstermBackend::new(io::sink());
let output = CrosstermBackend::new(io::stdout());
let mut screen = Screen::new(output, input.terminal_size()?)?;
let mut count = 0_i32;
loop {
draw_count(&mut screen, count)?;
match input.read_event()? {
Event::Key(key) if key.kind == KeyKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Escape => break,
KeyCode::Left => count -= 1,
KeyCode::Right => count += 1,
_ => {}
},
Event::Resize(size) => screen.resize(size)?,
_ => {}
}
}
Ok(())
}
fn draw_count(
screen: &mut Screen<CrosstermBackend<io::Stdout>>,
count: i32,
) -> Result<(), Box<dyn Error>> {
let view = View::column(
Align::Left,
[
View::row(
VerticalAlign::Top,
[
View::text("Count: ", TextStyle::new()),
View::text(count.to_string(), TextStyle::new()),
View::anchor("cursor"),
],
),
View::text("Left/Right changes the value; q exits", TextStyle::new()),
],
);
let size = screen.size();
let resolved = resolve(
&view,
Available::size(size.columns(), size.rows()),
)?;
let cursor = resolved.anchor("cursor").and_then(|anchor| {
Some(Position::new(
usize::try_from(anchor.x()).ok()?,
usize::try_from(anchor.y()).ok()?,
))
});
screen.draw(|frame| {
put_resolved(frame, &resolved);
frame.set_cursor(cursor);
})?;
Ok(())
}
fn put_resolved(frame: &mut Frame<'_>, resolved: &urushi::ResolvedView) {
for (row, cells) in resolved.rows().iter().enumerate() {
let mut column = 0;
for cell in cells {
frame.put(column, row, cell);
column += cell.width();
}
}
}

The visible states are controlled by the caller’s count, not by Screen:

State transitions

The caller updates application state; Screen presents each resulting frame and restores the shell on exit.

  1. stateCount: 0▏
  2. Right
    stateCount: 1▏
  3. Left
    stateCount: 0▏
  4. q
    stateRestored shell

Frame::put takes terminal-cell coordinates. Increment the column by StyledGrapheme::width(), not by one, because a resolved grapheme can occupy multiple cells. A zero-width cell or one that does not fit completely inside the frame is ignored. Frame::set_cursor(Some(position)) shows and moves the cursor; None hides it.

Resolve before calling draw. Layout can fail, while the draw closure is a non-fallible, draw-scoped description of the next complete cell frame. At the start of every draw, the working frame is reset; omitting a cell therefore means that the next frame contains a blank there.

Starting state What Screen writes State after success State after output failure
New, resized, or invalidated clear, complete cell frame, cursor, flush complete frame becomes the diff baseline baseline is not committed; next draw clears and reconstructs everything
Valid committed baseline changed cells only, cursor, flush working frame becomes the new baseline old baseline is retained; next draw clears and reconstructs everything
draw_with presentation changed cells, cursor, extra presentation, flush cells and presentation complete one transaction any presentation or flush error forces the same complete repair

The physical terminal may have accepted a prefix before returning an error. That is why retry is deliberately a clear plus complete redraw rather than a diff from either the old or attempted frame.

Pass the size from every Event::Resize to Screen::resize before resolving the next View:

use urushi_terminal::Event;
fn handle<W: urushi_terminal::CommandWriter>(
screen: &mut urushi_tui::Screen<W>,
event: Event,
) -> std::io::Result<()> {
if let Event::Resize(size) = event {
screen.resize(size)?;
}
Ok(())
}

Resize replaces both buffers and invalidates the physical baseline. The next draw therefore resolves against the new dimensions and emits the entire frame. If a graphics lifecycle owns terminal placements, clear that lifecycle before resizing; the graphics example below shows the ordering.

Screen can diff only against physical cells it still controls. Call invalidate when another owner has already changed the surface:

fn external_change<W: urushi_terminal::CommandWriter>(
screen: &mut urushi_tui::Screen<W>,
) {
// An external renderer changed what is physically visible.
screen.invalidate();
// The next draw clears and reconstructs the complete Urushi cell frame.
}

Use modify_surface when the direct terminal operation should go through the writer owned by Screen:

use urushi_terminal::{ClearRegion, Command, CommandWriter};
fn clear_directly<W: CommandWriter>(
screen: &mut urushi_tui::Screen<W>,
) -> std::io::Result<()> {
screen.modify_surface(|writer| {
writer.write_command(Command::Clear(ClearRegion::Screen))
})?;
Ok(())
}

modify_surface marks the baseline invalid before calling the closure, so the next draw is complete whether that closure succeeds or fails. Do not use it for output that should participate in the current frame transaction; use draw_with for that case.

Attach graphics to the same frame transaction

Section titled “Attach graphics to the same frame transaction”

draw_with writes cells and the requested cursor first, then gives the owned writer to an additional presenter, and commits the cell baseline only after the presenter and final flush succeed. This is the correct attachment point for retained Kitty graphics:

use std::io;
use urushi::{Available, ResolvedView, View, resolve};
use urushi_graphics::kitty::KittyLifecycle;
use urushi_terminal::{CommandWriter, PixelSize as CellPixelSize};
use urushi_tui::{Frame, Screen};
fn put_resolved(frame: &mut Frame<'_>, resolved: &ResolvedView) {
for (row, cells) in resolved.rows().iter().enumerate() {
let mut column = 0;
for cell in cells {
frame.put(column, row, cell);
column += cell.width();
}
}
}
fn draw_with_kitty<W: CommandWriter>(
screen: &mut Screen<W>,
kitty: &mut KittyLifecycle,
view: &View,
cell_pixels: Option<CellPixelSize>,
) -> io::Result<()> {
let size = screen.size();
let resolved = resolve(
view,
Available::size(size.columns(), size.rows()),
)
.map_err(io::Error::other)?;
screen.draw_with(
|frame| put_resolved(frame, &resolved),
|writer| kitty.present(view, &resolved, cell_pixels, writer),
)
}
fn resize_with_kitty<W: CommandWriter>(
screen: &mut Screen<W>,
kitty: &mut KittyLifecycle,
size: urushi_tui::TerminalSize,
) -> io::Result<()> {
screen.modify_surface(|writer| kitty.clear(writer))?;
screen.resize(size)
}

Keep one lifecycle for one terminal presentation. Before exit, call screen.modify_surface(|writer| kitty.clear(writer)) and then restore the terminal session. A draw_with presentation must not clear or replace the cell layer. For Sixel, a changed scene requires modify_surface(|writer| sixel.clear(writer)), a complete cell redraw, and then sixel.present through draw_with.

See Rendering and lifecycle for protocol selection, cell-pixel geometry, clipping, retry behavior, and fallback ownership.

Concern Owner with urushi-tui::Screen
Application model and state transitions caller
Frame timing and redraw decisions caller
Input reads and event dispatch caller
Raw mode, alternate screen, mouse/paste modes, restoration caller, usually TerminalSession
View layout urushi::resolve, invoked by caller
Working and committed cell buffers Screen
Cell diff, cursor command, flush, and failed-write repair Screen
Graphics protocol lifecycle caller, attached with draw_with and modify_surface

The complete method list is in the TUI reference and the generated urushi-tui API.