Move an existing app onto the shell
This guide is for an app that already draws with pito-header, pito-footer, pito-hourglass and pito-list directly, and keeps its own loop. Moving to the shell deletes that glue; the app’s screens and data stay as they are.
What the app keeps
Section titled “What the app keeps”-
Its screens’ content: what each one draws, its data, its rows and columns, its detail views. pito-list and pito-footer’s
Inputstay the app’s to use inside a screen, throughpito_tui::listandpito_tui::footer. -
Its words, every one, in any language:
Wordsfor the shell’s few places (the help word, the quit notice and question and its hint line, Yes and No, the confirm’s hint line, the too-small message, the hint under the hourglass),Activitiesfor the activity band and screen, the global hints, and each screen’s hints, facts, notices, labels and messages. An empty word is simply not drawn. -
Its keys beyond the shell’s: anything a screen’s
keytakes, and app-wide actions throughon_key. The shell’s own keys are the app’s to change too:nav_keys(pito-header’sNavKeys,NavKeys::NONEincluded),quit_keys,stop_keys,help(None), and the filter’sstart_keysandclear_keys. Mouse capture stays off unless the app turns it on withModes. -
Its workers and their messages: the app’s own enum is the
EinTui<E>, and its threads post through aWaker<E>. -
Its command line: the app parses its own flags and calls the shell’s headless helpers.
-
Its crash reporting: a panic hook the app installs before
runstill runs, after the shell has given the terminal back on a UI-thread panic. A hook that ends the process callspito_tui::restore()before it exits, so a panic on a worker thread gives the terminal back too:std::panic::set_hook(Box::new(|info| {pito_tui::restore();eprintln!("{info}");std::process::exit(101);}));
What the shell takes over
Section titled “What the shell takes over”| The app’s own code today | With pito-tui |
|---|---|
ratatui::init and ratatui::restore, bracketed-paste and mouse toggles, panic hooks that restore the terminal |
Tui::run (or Term::enter for a loop of its own) |
An input thread feeding a channel, an enum with an input arm, recv_timeout |
listen and Wake<E>, inside run |
| A frame pacer, a dirty flag, deadlines, draining a batch of wakes | run: Pace at 120 fps, synchronized updates, deadline and animating per screen |
| Polls on a timer, slow reads timed out, a clock threaded into drawing | Screen::deadline and tick, Screen::moment, Tui::advance headless |
| Code that hands the app’s asks to threads and routes their answers back | Screen::load (tracked by generation) and Cx::detach (one-off work) |
| Key routing: release, quit guard, confirm, input, help, nav | Tui::key, in that order |
| Quit guard wiring: busy count, wording, tick, deadline, notice, bar | Screen::busy, Words::again and Words::busy, quit, quit_window |
| A confirm gathered from several places | Cx::confirm and Screen::answer |
| Header assembly: title, help word, a lead beside it, a status fitted to the room, facts, a breadcrumb kept in step | Screen::lead, status_parts, facts, crumb, selected and back, keep_facts |
| Footer assembly: hints, notice, legend, confirm, input, version, height | Screen::hints, input, notice, legend, Cx::say, Words::leave; the version slot is wired |
| A panel of running work above the footer and a screen listing it, with a detail view | Activities, fed by Cx::activity, activities and forget; or the app’s own Band and Tui::overlay screen |
| A screen of its own opened over any other by a key, with unlit tabs and its own breadcrumb | Tui::overlay(section, keys, screen) |
Code that runs the app’s own CLI, parses its --progress json lines and stops it |
Cx::run(id, label, Command) (Cx::run_from when the lines come on stderr), Cx::stop(id), Screen::heard for each line and the exit, and command::read for a single line |
| A picker drawn over a screen, its keys and its answer | Cx::pick(Pick) and Screen::picked |
| A long-log or trace pane: scroll, page, ends, find, copy, a cache of built rows | Log; a long list takes pito-list’s Shared |
| Age and duration text, Braille bars, percent, spinners, label redraw timing | clock, progress, Activity::estimate |
| Frames compared cell by cell before and after a change, by hand | capture::capture and capture::compare behind --capture and --compare, capture::Recorder for the frames from before the move or from an app that keeps its own loop, and capture::compare_dirs for two captures |
A tone module mapped to four crates’ Styles |
Palette, and Cx::palette at runtime |
| A loading wrapper around the hourglass | Phase::Loading(label) |
| The too-small guard, a centred message, OSC 52, text helpers | min_size and Words::too_small, message, Cx::copy, text |
| Dump size and key parsing, text and ANSI output, a bench loop | dump::*, Tui::shot, Tui::bench |
Moving over, step by step
Section titled “Moving over, step by step”- One pin. Replace the four
pito-*lines inCargo.tomlwith the pito-tui line from Install, and import throughpito_tui::header,pito_tui::footer,pito_tui::hourglassandpito_tui::list. Keep ratatui and crossterm throughpito_tui::ratatuiandpito_tui::crossterm, or pin the same versions (0.30 and 0.29). - The palette. Build
Palette::new(accent), then set the tokens the app’s tone module had (good,bad,muted,basefor a painted background). Hand it toTui::palette. - The words. Move every shell-facing string into one
Wordsand the app-wide hints (? help,ctrl+c twice quit) intoTui::hints. - One screen at a time. Make each section or page a type that
implements
Screen<E>: its draw function becomesdraw, its hints and facts becomehintsandfacts, its loader becomesload(return the work as a closure) andloaded(take the answer from the app’s enum, no downcast), its “stop waiting” becomescancel, its detail view becomescrumbplusback, and its search box becomes aFilteror its ownInputreturned frominputwhiletypingis true. A password field is anInput::new().masked(true); a bracketed paste arrives inpaste. - Groups.
Tui::group(Group::new("Work"))before the screens that belong to it; screens before any group join one unnamed group, and a single group draws no groups row. - Workers. One-off work goes through
Cx::detach(move || E::Done(..))and comes back to the same screen’sevent. A long-lived watcher takescx.waker()(ortui.waker(index)beforerun) and posts withsend. A watcher that sees a source change sends an event whose screen callscx.changed("source"). A poll on a timer is a screen’sdeadlinewith the poll in itstick, which gives the next deadline. - Activities. If the app shows what it has running (operations,
exports, simulations), give
Tui::activitiesanActivitieswith the screen’s name (Section::new("Operations")), the key that opens it, its hints and its words (the empty message, the overflow line, the elapsed time, the facts). Whichever screen hears about the work callscx.activity(Activity::new(id, label, started)...)each time it changes, with the sameid;cx.activities(..)replaces the whole list, which suits an app that reads its work as a list, andcx.forget(id)drops one. The app’s own panel and its screen go; their data stays where it was. The activity screen always comes after the app’s screens, whereverTui::activitiessits in the builder chain, so the screens keep the indicesgo,wakerandeventuse. The demo’s fake jobs (Homeinexamples/demo.rs) are the pattern. - The loop goes. Replace the app’s
runwithTui::run(); delete the pacer, the terminal setup and restore, the restoring panic hooks, the input thread and the key routing. The app’s crash hook stays where it is, installed beforerun; if it ends the process, it callspito_tui::restore()before it exits. - Headless flags. Wire
--dump WxHtodump::sizeandTui::shot,--keystodump::keys,--ansitodump::ansi,--loadingtoshot(.., settle: false),--at MStoTui::advanceand a secondframe,--bench NtoTui::bench, and--capture DIRand--compare DIRtocapture::captureandcapture::comparewith acapture::Walkof the app’s deeper states (exit 1 when a frame differs);mainandwalkinexamples/demo.rsdo exactly this. A dump or a walk with fixtures builds the screens from fixture data, so the same build gives the same frames. A game that keeps its dev tools out of the shipped build puts these behind its own feature. - Check. Before the move, record every screen and deeper state at
the sizes the app cares about with the old build, through
capture::Recorder(Prove the switch); after it, compare. The header, footer and spacing should match, since the shell draws them with the same crates and the same layout (a one-cell side margin for the header and footer, two cells for the content, one row above the footer). Then delete the captures: they are a safety net for the move, not a test to keep. - What the app had built in. Its own pickers become
Cx::pick, its log and trace panes aLog(a long list a pito-listShared), its age and duration textclock, its bars and spinnersprogress, and the code that ran its own CLI and read the progress linesCx::run(orCx::run_fromfor lines on stderr), withCx::stopfor its stop andScreen::heardfor what it read from the lines and the exit; the app keeps its own words on the band by posting its activity for that id.
Prove the switch
Section titled “Prove the switch”capture::capture walks an app that already runs on Tui, so it can’t
record the old build. capture::Recorder writes the same frames from the
buffers the app’s own headless dump draws: record them with the old build,
switch to Tui::run, and capture::compare walks the new build against
them, scenario names and sizes matched.
Recorder::new(dir)wants a directory that is absent or empty, ascapturedoes.screen(index, name, &buffer)saves a screen the way a walk names it:indexis its place among the new build’s screens, in the order the app adds them withTui::screen(the activity screen comes after all of them), andnameits section’s name.record(name, &buffer)saves a deeper state under the name the new build’scapture::Scriptcarries, sorecord("Next drilled in", ..)meetsScript::new("Next drilled in", keys).- A frame is the whole terminal at one size, as a
TestBackendholds it after a draw; its size is the buffer’s own. Record each state at every size the walk lists. The same state at the same size twice is an error, so two states never share a name by accident. - Both builds draw from the same fixture data at the same moment, as any capture does.
Recorder::against(dir)takes the same calls but compares each frame with the one of the same name indir, an existing capture, instead of writing it;finish()then lists the differences, ascomparedoes. So one function that draws every state into a&mut Recordergives an app both--capture DIR(Recorder::new) and--compare DIR(Recorder::against), as below.capture::compare_dirs(before, after)compares two captures already on disk, frame by frame and cell by cell: each frame in both, each one onlyafterholds (Added) and each one onlybeforeholds (Removed). Either may come fromcapture::captureor from aRecorder.
use std::path::Path;
use pito_tui::capture::Recorder;use pito_tui::ratatui::{Frame, Terminal, backend::TestBackend, buffer::Buffer};
struct OldApp;
impl OldApp { fn open(_state: &str) -> Self { OldApp }
fn draw(&self, _frame: &mut Frame) {}}
fn shot(state: &str, (width, height): (u16, u16)) -> Buffer { let app = OldApp::open(state); let Ok(mut terminal) = Terminal::new(TestBackend::new(width, height)); let Ok(_) = terminal.draw(|frame| app.draw(frame)); terminal.backend().buffer().clone()}
fn states(recorder: &mut Recorder) -> std::io::Result<()> { for size in [(80, 24), (120, 34)] { recorder.screen(0, "Home", &shot("home", size))?; recorder.screen(1, "Next", &shot("next", size))?; recorder.record("Next drilled in", &shot("next/detail", size))?; } Ok(())}
fn main() -> std::io::Result<()> { let args: Vec<String> = std::env::args().collect(); match args.get(1..3) { Some([flag, dir]) if flag == "--capture" => { let mut recorder = Recorder::new(Path::new(dir))?; states(&mut recorder)?; println!("{} frames", recorder.frames()); } Some([flag, dir]) if flag == "--compare" => { let mut recorder = Recorder::against(Path::new(dir))?; states(&mut recorder)?; let differences = recorder.finish()?; for difference in &differences { println!("{difference}"); } if !differences.is_empty() { std::process::exit(1); } } _ => {} } Ok(())}After the switch, capture::compare with
Walk::new().sizes([(80, 24), (120, 34)]).script(Script::new("Next drilled in", keys)) and the new build’s Fn() -> Tui<E> lists each cell that
differs, each frame only the new build draws (Added) and each one it no
longer reaches (Removed). An empty list proves the switch; then delete the
directory.
An app that keeps its own loop (its own Term, listen, Waker, Pace
and wait_until) proves a change the same way without moving: --capture tmp/before with the old build, then --compare tmp/before with the new
one, or --capture tmp/after with the new one and
capture::compare_dirs(Path::new("tmp/before"), Path::new("tmp/after")).
An app that already has a section trait
Section titled “An app that already has a section trait”Most of it maps one to one: a section’s name becomes the header::Section
passed to Tui::screen; its data sources, phase, cancel and retry become
sources, phase, cancel and Cx::reload; a loader that returns a job
and a delivery to downcast becomes load returning a closure and loaded
taking the app’s own enum, with the generation kept by the shell; its notice
becomes Cx::say; its pending confirm becomes Cx::confirm with answer;
its asks become Cx::detach. A job that needs a connection or a client opens
or clones its own inside the closure, since it runs on a worker.
An overlay that lists the app’s running work becomes Activities. Any other
overlay that sits over every section becomes a screen of its own (in its own
group if it should read as one), or stays the app’s drawing inside a screen
with crumb naming where the user is.