Skip to content
Select theme

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.

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 filter
draw 3 · log 2@128B · key (none yet) · m

Here is step 7’s binary, started somewhere with no controlling terminal at all — a cron job, a CI runner, a build agent:

Terminal window
rlog > out.txt < /dev/null
echo $?
Error: Os { code: 6, kind: Uncategorized, message: "Device not configured" }
1

Exit 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:

Terminal window
head -c 48 out.txt | cat -v
wc -c < out.txt
^[[?1049h^[[?1000h^[[?1002h^[[?1003h^[[?1015h^[[?1006h
5109

So 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:

Terminal window
rlog > out.txt
echo $?
rlog needs a terminal, and stdout is not one
run it in a terminal window; redirecting its output leaves nothing to see
2

Status 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.

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:

Cargo.toml
[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 = true
codegen-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:

Terminal window
cargo build --release
file target/release/rlog
otool -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.04s
target/release/rlog: Mach-O 64-bit executable arm64
target/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.

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:

Terminal window
rustup target add x86_64-apple-darwin # once, if missing
cargo build --release --target x86_64-apple-darwin
lipo -create target/release/rlog target/x86_64-apple-darwin/release/rlog -output rlog
lipo -info rlog
Architectures in the fat file: rlog are: x86_64 arm64

796,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:

Terminal window
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
file target/x86_64-unknown-linux-musl/release/rlog
target/x86_64-unknown-linux-musl/release/rlog: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped

1,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.

A recipient gets a tarball and a checksum:

Terminal window
cd /tmp/rlog-dist
tar czf rlog-0.1.0-universal-apple-darwin.tar.gz rlog
shasum -a 256 rlog-0.1.0-universal-apple-darwin.tar.gz
bc72f4151912133cb02532ee064d233fa28fdc8a30dc9dcaf11f46617ed4b917 rlog-0.1.0-universal-apple-darwin.tar.gz

The 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:

Terminal window
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.

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.

Terminal window
cargo run --release -- ./app.log

The 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.