Releasing a cross-platform TUI
Step 8 of 8. Step 7 finished the interface: the process
table, the followed log, the history sparkline, the themes and the help overlay. This step ships it. It
covers what the program needs from a terminal and what it says when it does not get it, a release
binary 39% smaller than the default one, and the same source built for both Apple architectures and
for Linux. Start from tag tui-ch07; the end state is tagged tui-ch08.
What a terminal program is owed
Section titled “What a terminal program is owed”A TUI is a conversation with one terminal, and rlog asks it for three things.
Raw mode and the alternate screen. crossterm asks the controlling terminal — /dev/tty on unix —
for raw mode, so keys arrive one at a time without the line discipline, and for the alternate screen,
so the frame has somewhere to live that the shell’s scrollback does not. Both are modes the program
has to give back on the way out, which is what the teardown in step 2 does.
A stdout that is a terminal. The frame is painted through stdout. A pipe is not a screen, and the next section is about what happens when the frame has nowhere to go.
The sixteen ANSI colours, and nothing more. rlog’s whole palette is four of them — cyan, yellow,
dark grey and black, with the three themes recolouring the same slots. The third theme, mono, uses
no colour at all, only bold, dim and reversed, which is the proof that the frame survives a terminal
that ignores colour. crossterm writes the named colours as the indexed 256-colour form — 38;5;6 for
cyan, 48;5;8 for dark grey — not as truecolour sequences, so there is no colour capability to
detect and no fallback to write.
What rlog does not ask for is TERM. On unix, nothing in the path the program uses reads it:
crossterm’s terminal backend writes the escape sequences directly, with no terminfo involved anywhere
in the crate. (TERM is consulted on Windows, where the console needs virtual-terminal processing
switched on before it understands the sequences.) The capture below is the program running with
TERM=dumb, at 100×30 — the same frame it draws under xterm-256color, because the program never
asked:
rlog · 704 processes · 15.7/64.0 GB resident┌ processes [26/704] · cpu desc ─────┐┌ log [1/2] ─────────────────────────────────────────────────┐│ PID COMMAND CPU% MEM%││2026-10-25T12:00:01Z INFO rlog starting, following app.log ││ 716 fileproviderd 0.0 0.1││2026-10-25T12:00:01Z INFO tick 250ms, log poll 250ms, proce││ 893 SogouInput 0.0 0.4││ │The layout degrades the same way when the terminal is small. At 40×12 the panes stack, the table
keeps its columns, the history pane keeps its subject, and nothing crashes — the program exits 0 on
q exactly as it does at 100×30:
rlog · 705 processes · 15.7/64.0 GB res┌ processes [45/705] · cpu desc ───────┐│ PID COMMAND CPU% MEM%││ 901 php-fpm 0.0 0.0││ 964 WorkBuddy Helper ( 0.0 0.2││▸1 launchd 0.0 0.0│└──────────────────────────────────────┘┌ cpu history · launchd (pid 1) · 0.0% ┐│ │└──────────────────────────────────────┘press / to filterdraw 3 · log 2@128B · key (none yet) · mThe run with nowhere to go
Section titled “The run with nowhere to go”Here is step 7’s binary, started somewhere with no controlling terminal at all — a cron job, a CI runner, a build agent:
rlog > out.txt < /dev/nullecho $?Error: Os { code: 6, kind: Uncategorized, message: "Device not configured" }1Exit status 1, and a message that names no program and suggests no fix. Code 6 is the errno that
comes back from opening /dev/tty when there is no terminal to open.
The worse case is subtler: a controlling terminal exists, but stdout is redirected — a pipe, a cron
job’s mail, > file. Then raw mode succeeds, the frame is written into the redirect, and the user
watches a blank terminal. Both halves of that were measured here: the terminal itself received
nothing, and the file it was told to write held 5,109 bytes, 317 of them escape bytes, beginning
with the alternate screen:
head -c 48 out.txt | cat -vwc -c < out.txt^[[?1049h^[[?1000h^[[?1002h^[[?1003h^[[?1015h^[[?1006h5109So step 8 adds one guard at the top of main: if stdout is not a terminal, say so and stop. The
check is io::stdout().is_terminal(), in the standard library since 1.70 — no crate to add. The
same redirect now answers with two lines on stderr (stderr is still the terminal even when stdout is
not) and exit status 2:
rlog > out.txtecho $?rlog needs a terminal, and stdout is not onerun it in a terminal window; redirecting its output leaves nothing to see2Status 2 rather than 1, because 2 means “you invoked me wrong” and a wrapper script can tell that from “something broke” — and a panic is a third thing again, exiting 101.
A binary worth shipping
Section titled “A binary worth shipping”The default release profile builds a working binary and then spends a third of it on things a recipient does not need. The measurements below are this machine, arm64, one change at a time:
| the build | bytes |
|---|---|
cargo build --release, as step 7 left it |
1,280,896 |
| the same, with the terminal guard | 1,280,784 |
strip = true |
1,048,000 |
lto = true, codegen-units = 1 |
836,016 |
panic = "abort" |
786,192 |
The guard itself is a few dozen bytes; the 112-byte drop in the second row is the module comment
getting shorter, not the check. Everything after it is the profile, and the whole stanza is in
Cargo.toml at the end of this step:
[package]name = "rlog"version = "0.1.0"edition = "2024"
[dependencies]crossterm = "0.29"ratatui = "0.30"sysinfo = "0.39"tui-input = "0.15"nucleo = "0.5"
# A release build is one that leaves this machine, so the profile is tuned for# the recipient: smaller, and built as one unit.[profile.release]# Drop the symbols. Nobody debugs a shipped rlog, and this is a fifth of the# file.strip = true# Inline across crate boundaries, and compile the whole graph as one unit# instead of the default sixteen. Slower to build; the payoff is size.lto = truecodegen-units = 1# A panic in a TUI means the terminal is already in a state worth leaving, and# the panic hook restores it before the abort. Tests are unaffected: they run# in the test profile, which keeps unwinding.panic = "abort"Three notes on that stanza. lto with one codegen unit is the slow line — the build takes longer,
and the payoff is the 212,000 bytes between rows three and four. panic = "abort" does not affect
the tests, because cargo test builds the test profile, which keeps unwinding: 36 tests still pass.
And the panic hook still runs before the abort, so a panic in a shipped rlog still restores the
terminal before the process dies — that was the hook’s whole job since step 2.
The shipped binary links nothing but the system:
cargo build --releasefile target/release/rlogotool -L target/release/rlog | head -4 Compiling rlog v0.1.0 (/Users/rango/www/hackingwithrust/code/tui-ratatui) Finished `release` profile [optimized] target(s) in 5.04starget/release/rlog: Mach-O 64-bit executable arm64target/release/rlog: /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0) /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit (compatibility version 1.0.0, current version 275.0.0) /System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory (compatibility version 1.0.0, current version 1.0.0)Everything Rust — the runtime, ratatui, crossterm, sysinfo, nucleo — is inside the file; what is left is the operating system. The path in parentheses is this machine’s working copy; a reader following along sees their own.
One source, three targets
Section titled “One source, three targets”Nothing in src/main.rs names a platform: the only cfg in the file is #[cfg(test)]. crossterm
picks its backend at compile time, so a target is a flag, not a branch.
Both Apple architectures, then one file that holds both:
rustup target add x86_64-apple-darwin # once, if missingcargo build --release --target x86_64-apple-darwinlipo -create target/release/rlog target/x86_64-apple-darwin/release/rlog -output rloglipo -info rlogArchitectures in the fat file: rlog are: x86_64 arm64796,880 bytes of x86_64 and 786,192 of arm64 become one 1,589,008-byte universal binary: two copies of the code, one per instruction set, which is all “universal” means.
Linux needs one extra stanza, because the default linker on a Mac is Apple clang and it cannot
produce an ELF file. rustc ships its own LLD, and it resolves the name without being on PATH; point
the target at it in your own .cargo/config.toml:
[target.x86_64-unknown-linux-musl]linker = "rust-lld"(In this site’s repository that file is machine-local and deliberately untracked; in a normal checkout it is just yours.) Then:
rustup target add x86_64-unknown-linux-muslcargo build --release --target x86_64-unknown-linux-muslfile target/x86_64-unknown-linux-musl/release/rlogtarget/x86_64-unknown-linux-musl/release/rlog: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped1,039,656 bytes, statically linked against musl — no glibc to ship alongside, which is the whole
point of the musl target. It cannot run on this machine: there is no Linux kernel here, so the
evidence is file and the build’s exit status, not a run. Windows is the one target not built here.
The source would need no change, but the toolchain that links a Windows binary is not on this Mac,
and a guide that claims a build it has not run is lying.
Handing it over
Section titled “Handing it over”A recipient gets a tarball and a checksum:
cd /tmp/rlog-disttar czf rlog-0.1.0-universal-apple-darwin.tar.gz rlogshasum -a 256 rlog-0.1.0-universal-apple-darwin.tar.gzbc72f4151912133cb02532ee064d233fa28fdc8a30dc9dcaf11f46617ed4b917 rlog-0.1.0-universal-apple-darwin.tar.gzThe version in the name is the one in Cargo.toml — 0.1.0 here — and a real release tags it in git
before the archive is cut. For a Rust user there is a shorter route:
cargo install --path . --locked Installing /Users/rango/.cargo/bin/rlog Installed package `rlog v0.1.0 (/Users/rango/www/hackingwithrust/code/tui-ratatui)` (executable `rlog`)--locked works because Cargo.lock is committed: the recipient compiles the same dependency
versions this article did. The installed binary is the same 786,192-byte file, on PATH, and it
answers a redirect with the guard’s message instead of an errno. cargo uninstall rlog takes it away
again.
Shooting a TUI
Section titled “Shooting a TUI”The picture on the track overview was made without a window and without a screen-recording permission. The release binary ran on a pseudo-terminal 100 columns by 30 rows — the size every capture in this track was taken at — for two and a half seconds, and the raw bytes it wrote were replayed through a terminal emulator into the image. That is the whole recipe: a pty of a fixed size, a run, and something that turns the escape sequences back into a grid. It is also why the shot matches the prose. The same 100×30 frame the articles paste as text is what the picture shows, drawn by the program rather than typed out.
Running it
Section titled “Running it”cargo run --release -- ./app.logThe first second still shows 0.0% everywhere: CPU usage is a delta between two refreshes, so the
baseline refresh and the first real numbers arrive together, a second apart. q quits; the key list
is on ?.
This is the last step of the track — eight steps from an empty directory to a binary on PATH. The
track overview has the full list;
step 5 is the tail the guard
protects, step 6 the table, and
step 7 the interface this step ships unchanged.