A terminal UI framework for Rust built on OpenTUI's native renderer.
OpenTUI's native library keeps a retained scene of nodes, lays it out with Yoga, paints cells, and encodes terminal output. This workspace drives it from Rust, without JavaScript:
crates/opentui-sysdeclares the whole C ABI (opentui.h), generated from the header.crates/opentuiis the framework: widgets, an event loop, focus, mouse and keyboard input, timers, and tests that run without a terminal. Its lower layers wrap the ABI safely and run the terminal.crates/opentui-sshserves apps over SSH, one app per session, as Core's@opentui/sshdoes.
use opentui::prelude::*;
fn main() -> opentui::Result<()> {
let app = App::new(AppOptions::default())?;
let status = Text::new(&app, "Pick a task").fg(app.theme().muted);
let list = Select::new(&app).options(["Build", "Test", "Ship"]).grow(1.0);
app.root()
.add(&View::new(&app).border(BorderStyle::Rounded).title(" Tasks ").padding(1).grow(1.0).child(&list))
.add(&status);
list.on_select(move |_cx, _index, option| {
status.set_text(format!("Selected {}", option.name));
});
app.on_key(|cx, key| {
if key.matches("q") {
cx.quit();
}
});
list.focus();
app.run()
}The work follows PLAN.md.
The crates link the native library of this checkout,
packages/native/lib/<artifact directory>. They do not build it. Build it
first, then run Cargo:
bun install
bun run build # at the repository root, or `bun run build:native` in packages/core
cargo test --manifest-path packages/rust/Cargo.tomlTo link other artifacts, such as an npm platform package or a native archive
of a release, set OPENTUI_LIB_DIR to their directory. It wins over the
checkout's build. Give an absolute path: Cargo reads a relative one from
crates/opentui-sys. A Cargo git dependency on this repository has no native
build, so it needs OPENTUI_LIB_DIR.
These platforms are supported:
| Platform | Rust target | Artifact directory |
|---|---|---|
| Linux x86_64 with glibc | x86_64-unknown-linux-gnu |
x86_64-linux |
| macOS 13 or later, Apple chips | aarch64-apple-darwin |
aarch64-macos |
| macOS 13 or later, Intel chips | x86_64-apple-darwin |
x86_64-macos |
| Windows 10 1809 or later | x86_64-pc-windows-msvc, x86_64-pc-windows-gnu |
x86_64-windows |
The build picks the directory for the target it builds.
OpenTUI's macOS build needs the macOS SDK, which comes with the Xcode Command
Line Tools (xcode-select --install). On a Mac, bun run build in
packages/native builds the Mac's own architecture; add the other one with
zig build -Doptimize=ReleaseFast -Dlibrary-target=x86_64-macos.13.0 (or
aarch64-macos.13.0). The Windows build needs no SDK, so Zig makes it on any
system: zig build -Doptimize=ReleaseFast -Dlibrary-target=x86_64-windows-gnu.
crates/opentui-sys/src/generated.rs is generated from
packages/native/include/opentui.h: bun run generate:abi in packages/core
writes it, and bun run check:abi fails when it is stale. Do not edit it. The
bindings are valid only for the exact header they were generated from, and the
build fails if the artifacts' header differs.
By default the crate links the shared library: libopentui.so on Linux,
libopentui.dylib on macOS, and opentui.dll on Windows, through its import
library opentui.lib. On Linux and macOS, the workspace's tests and examples
find it through an rpath; other executables need their own rpath,
LD_LIBRARY_PATH on Linux, or DYLD_LIBRARY_PATH on macOS, or can link
statically with the static feature. Windows has no rpath: the build copies
opentui.dll where cargo run and cargo test find it, and other
executables need it beside them or on PATH.
On Windows, the app turns on the console's virtual terminal input and output,
so the input parser reads the same sequences as on Unix. Windows has no job
control, so Ctrl+Z reaches key handlers instead of suspending the app.
Ctrl+Break, closing the console, logoff, and shutdown end App::run as
signals do on Unix. TerminalView runs programs on a pseudoconsole (ConPTY),
and pty::shell() is %COMSPEC%.
Features:
static: linklibopentui.a. On macOS this also links the system frameworks OpenTUI uses for the clipboard and audio. Not supported on Windows: OpenTUI's Windows archive is built for MinGW with libc++, which neither Rust Windows toolchain links.tokio:App::spawnruns futures on a Tokio runtime and finishes them on the UI thread.log:console::init_loggershows thelogcrate's records in the debug console.tree-sitter:Code,Diff,Textarea, and Markdown code blocks highlight with tree-sitter. It bundles Core's grammars (JavaScript, TypeScript and TSX, Markdown, Zig) and Rust.sh run.shturns it on.
Every widget is a cheap Clone handle over a native node. Property methods
take &self and return a new handle, so one method both builds a widget and
changes it later:
let status = Text::new(&app, "Ready").fg(Color::GREEN).bold();
// Later, from an event handler:
status.set_text("Saved").fg(Color::CYAN);Changes go to native code at once and schedule a frame. Frames are painted at
most AppOptions::max_fps times per second, and only when something changed.
A parent keeps its children alive. A detached widget is destroyed when its last handle drops. Handlers may capture widget handles, including their own widget's; the app breaks those reference cycles when it ends.
Setters do not return Result. If native code rejects a change, for example
an infinite width, the app records the error and App::run returns it at the
end of that turn, after restoring the terminal.
Layout is flexbox, computed natively by Yoga. Lengths are cells; integers
convert, and Length::percent(50.0) gives percentages. The Widget trait has
the layout methods: width, height, min_*/max_*, grow, row,
column, gap, padding*, margin*, justify, align_items, absolute
with position, and visible.
grow(n) works like CSS flex: n: the widget fills free space, shared with
growing siblings by factor, whatever its content. A widget whose content
outgrows its space scrolls or clips instead of pushing its siblings away.
Widgets without grow size to their content. Scrolling widgets (ScrollBox,
Select, Table, Code) need a height or a grow factor.
Keys go to the focused widget first, then to each ancestor, then to
App::on_key. A widget's own handlers run before its built-in behavior:
cx.prevent_default()skips built-in behavior, such as an input inserting the typed character. The key still bubbles.cx.stop_propagation()stops the key from reaching ancestors and the app.App::on_key_capturehandlers run before any widget, for shortcuts that must win over text input.
Unless a handler claims them, Tab and Shift+Tab move focus in tree order, Ctrl+C quits, and Ctrl+Z suspends to the shell.
App-level handlers (App::on_key, on_mouse, on_message, and the rest)
return a HandlerId; App::remove_handler removes one, for screens that come
and go.
Widgets with built-in keys take Core's default bindings, which you can change
per widget. Each widget names what its keys can do in an action enum
(EditorAction, SelectAction, TabSelectAction, SliderAction,
ScrollAction, TableAction):
let input = Input::new(&app)
.bind("ctrl+s", EditorAction::Submit) // add or replace a binding
.unbind("ctrl+w"); // the key now bubbles to the app
let list = Select::new(&app).set_keymap(Keymap::new().bind("n", SelectAction::MoveDown));
list.perform(SelectAction::Last); // run an action without a keyAn unbound key, or an action that does not apply (Up on a horizontal slider),
bubbles on like any other key. Shortcut parses and prints names such as
ctrl+shift+a, for help screens and configuration files.
Mouse events go to the widget under the pointer and bubble the same way. A
press captures the pointer, so drags keep reaching the widget they started on.
A left click focuses the nearest focusable widget. on_hover reports enter
and leave for a widget and its descendants. on_click reports a press and
release. For drag and drop, on_drag_over reports a drag from another widget
entering and leaving, and on_drop reports where it ends.
Dragging selects text across Text, Code, Input, Textarea, TextTable,
and TerminalView widgets, as in Core: the selection covers every selectable
widget between the press and the pointer, starting within the pressed widget's
parent and widening as the pointer leaves it, so a drag inside a dialog does
not select the text beneath it. In a TextTable, a drag selects within one
cell, then down one column, then across whole rows, and never the grid lines.
Double-click selects a word, triple-click a line, and Ctrl+click extends the
selection. On release, App::on_selection handlers get the text and, with
AppOptions::copy_on_select, it is copied to the clipboard through OSC 52.
App::selected_text joins the pieces in reading order.
Extmarks mark ranges of an Input or Textarea that move with edits, as in
Core: a styled decoration, or an atomic token such as a mention that the
cursor jumps over and Backspace removes whole:
let start = input.cursor_offset();
input.insert("@main.rs");
input.add_extmark(start..input.cursor_offset(), ExtmarkOptions::default().atomic().style(Style::new().fg(Color::CYAN)));App::copy_to_clipboard copies through the terminal (OSC 52) and, when the
session is local, through the system clipboard too. App::read_clipboard and
App::paste_from_clipboard read text from the system clipboard of the machine
the app runs on; App::read_clipboard_as reads the first of several MIME
types, such as image/png before text/plain. Bind EditorAction::Paste to
a key to paste into an input. App::set_program_status sends OSC 7501
program status reports.
| Widget | What it is |
|---|---|
View |
A flexbox container with optional border, titles, and background. app.root() is one. |
Text |
Styled text that wraps. Spans carry colors, attributes, and hyperlinks. |
Input |
A single-line field with Core's editing keys, undo, selection, and paste. |
Textarea |
Multi-line editing with wrapping and optional syntax highlighting. |
Select |
A vertical list with descriptions. |
TabSelect |
A row of tabs. |
Slider |
A value between a minimum and a maximum, with half-cell precision. |
ScrollBox |
A clipped, scrollable area: scrollbars with optional arrows, sticky edges, wheel acceleration. |
Table |
Aligned columns with a header, selection, and scrolling. |
TextTable |
A bordered grid of styled cells that wrap, with columns fitted to the width, for reports. |
Code |
Read-only text with line numbers, tree-sitter highlighting, and tail-following. |
LineNumbers |
A gutter beside a Code view or Textarea, with per-line signs, colors, and custom numbers. |
Diff |
A unified diff in one column or side by side, with line numbers, signs, and colored lines. |
Markdown |
Rendered or source Markdown with nested lists, tables, code, and code-block renderers; streamed text updates only what changed. |
TerminalView |
A shell or any program on a pseudo-terminal, emulated natively, with scrollback and selection. |
ImageView |
A picture, drawn with the Kitty protocol, Sixel, or half blocks. |
FrameBuffer |
A grid of cells you draw into yourself, which native code shows every frame. |
Custom<T> |
A widget you paint yourself by implementing Component. |
Widgets read their default colors from the app's Theme when created.
App::new asks the terminal for its background color and picks
Theme::dark() or Theme::light() to match; AppOptions::with_theme sets
one theme for every terminal, and Theme::terminal() uses the terminal's own
colors. When the system later switches between dark and light mode,
App::on_theme_mode handlers hear about it; App::set_theme changes the
background and the colors new widgets start with, and the handler recolors or
rebuilds the existing ones. Set AppOptions::palette_size to
also read the terminal's palette (App::palette), which native code uses to
pick palette colors on terminals without true color.
With the tree-sitter feature, Code highlights its text as the filetype you
give it, as Core's CodeRenderable does. Diff and Textarea take a filetype
too, and Markdown uses each fence's tag:
let source = Code::new(&app).filetype("rust").line_numbers(true).grow(1.0);
source.set_code(std::fs::read_to_string("src/main.rs")?);
// Extensions, file names, and fence tags resolve too.
source.filetype(opentui::syntax::filetype_for_path("app.tsx"));Highlight queries name each piece of the text (keyword, string,
function.method), and a SyntaxTheme maps names to styles in Rust (Core's
TypeScript calls it a SyntaxStyle; SyntaxTheme::to_syntax_style builds the
native table of that name). A name without a style takes the style of its longest dotted prefix
that has one. Widgets start with SyntaxTheme::from_theme, which matches the
app's dark or light theme. syntax::register adds a grammar, such as any
tree-sitter-* crate:
use opentui::syntax::{Grammar, register};
register(
"python",
Grammar::new(tree_sitter_python::LANGUAGE, tree_sitter_python::HIGHLIGHTS_QUERY),
)?;Injections highlight embedded languages: code in Markdown fences, inline
Markdown, Rust in macro calls. Changes between frames cost one highlight of the
whole text, at the next frame; cursor moves, scrolling, and idle frames cost
none. A highlight costs time in proportion to the text, so highlighting large
Textareas makes typing slow (about 9 ms per keystroke for 17 KiB of Rust).
Without the feature, or without a grammar for the filetype,
the text is plain.
Implement Component and wrap it in Custom. Painting uses local coordinates
and is clipped to the widget:
struct Meter {
level: f32,
}
impl Component for Meter {
fn paint(&self, painter: &mut Painter<'_>) {
let filled = (painter.width() as f32 * self.level) as u32;
painter.fill(0, 0, filled, 1, Color::GREEN);
}
fn key(&mut self, _cx: &mut Cx<'_>, key: &KeyEvent) -> bool {
let step = if key.matches("right") { 0.1 } else if key.matches("left") { -0.1 } else { return false };
self.level = (self.level + step).clamp(0.0, 1.0);
true
}
}
let meter = Custom::new(&app, Meter { level: 0.5 }).height(1).focusable(true);
meter.update(|meter| meter.level = 0.8);Custom::measured sizes the widget with Component::measure during native
layout, when its width or height is not set.
To compose existing widgets instead, implement Widget for your own type by
returning the root widget's element().
TerminalView shows a program running on a pseudo-terminal, as Core's
EmbeddedTerminalRenderable does. Native code (Ghostty's emulator) parses the
program's output and keeps its screen and scrollback; the widget draws it,
follows its own size, and encodes keys, mouse events, pastes, and focus
changes in whatever protocol the program asked for:
let terminal = TerminalView::new(&app).grow(1.0).spawn(opentui::pty::shell())?;
terminal.on_exit(|cx, _status| cx.quit());
app.on_key_capture(|cx, key| {
if key.matches("ctrl+q") {
cx.quit();
cx.stop_propagation();
}
});
terminal.focus();A focused terminal takes every key that sends something, Tab, Ctrl+C, and
Ctrl+Z included, so app-wide shortcuts belong in App::on_key_capture. While
the program tracks the mouse, mouse events go to it; otherwise dragging
selects text, double and triple clicks select words and lines, and the wheel
scrolls back through the output. A wheel step the view cannot take, down at
the live screen or up at the top of the history, goes on to a surrounding
ScrollBox. TerminalView::viewport says where the view sits.
spawn runs the command with opentui::pty::Pty: a new session on a new
pseudo-terminal, whose bytes move on a background thread, so the event loop
never blocks. Output and the exit status arrive through the app's proxy;
dropping the widget hangs up the program. To run a program some other way,
such as over SSH, feed its output to TerminalView::write and send what
on_data hands you to its input.
The terminal draws into an offscreen native::Buffer, a grid of cells that
keeps its content between frames. Apps can use them too: draw with clear,
fill, text, cell, draw_box, and compose, and show the result from a
paint hook with Painter::draw_buffer, or use a FrameBuffer widget, whose
buffer native code shows where the widget is laid out. Create one with
Buffer::new(app.session().context(), width, height, app.width_method()), so
it measures text as the terminal does.
opentui-ssh runs one app per SSH session, each on a thread of its own. The
handler gets the Session and builds the app the way main would for a local
one:
use opentui::prelude::*;
use opentui_ssh::{Auth, HostKey, ServerConfig};
fn main() -> Result<(), opentui_ssh::Error> {
let config = ServerConfig::new()
.auth(Auth::empty().authorized_keys_file("authorized_keys")?)
.host_key(HostKey::File("host_key".into()));
let server = opentui_ssh::serve("0.0.0.0:2222", config, |session| {
if session.user() == "root" {
return session.deny("Log in as yourself.");
}
let app = session.app(AppOptions::default())?;
app.root().add(&Text::new(&app, format!("Hello, {}!", session.user())));
app.run()
})?;
server.close_on_ctrl_c()
}The app's input, output, and window size come from the SSH channel, and it
picks its light or dark theme from the client's terminal. App::run returns
when the app quits, the client disconnects, an idle or maximum timeout passes,
or the server closes; except after a disconnect, the app restores the client's
terminal first. An error or panic in a handler ends only its own session and
goes to ServerConfig::on_error, with the client's address and user name.
- Authentication:
Auth::open()(ServerConfig's default, which warns throughon_errorwhen listening beyond the loopback interface), orAuth::empty()withany_public_key,authorized_keys,allow_public_key, andpassword.Session::identitysays who got in and how; for keys, the fingerprint is what was proven. - Host keys:
HostKey::Ephemeral(the default, new on every start),HostKey::File(loaded, or generated and saved readable by its owner only), orHostKey::Keysfor keys you supply. - Limits: one session per connection and 100 in all by default, optional idle and maximum timeouts, and keepalives that find vanished clients.
- Shutdown:
Server::closewaits until every app has restored its client's terminal and the client has taken that output, bounded by the apps' close timeouts;ServerConfig::close_timeoutcaps it for a prompter shutdown.
sh run.sh chat starts a chat room on port 2222 where every ssh -p 2222 localhost gets its own app.
Underneath, opentui::remote works with any transport. A RemoteTerminal
stands in for the client's terminal, and its RemoteLink takes the client's
input and size in and hands output out, with bounded buffers that hold a slow
client back instead of dropping bytes. App::remote(terminal, options) runs an
app on it without touching the local terminal, signals, or stderr.
audio::Audio is Core's audio engine: it decodes WAV, MP3, and FLAC bytes
into sounds and mixes up to 32 voices at 48 kHz. Creating one opens no device;
start opens the default playback device, whose own thread mixes from then
on:
use opentui::audio::Audio;
let audio = Audio::new(app.session().context())?;
let bell = audio.load(&std::fs::read("bell.mp3")?)?;
audio.start()?;
audio.play(bell, 0.4)?;start_without_device mixes only when mix asks, so tests can check what
would play without a sound.
app.set_timeoutandapp.set_intervalrun callbacks on the UI thread;cancel_timerstops them.app.animate(|cx, elapsed| ...)runs before every frame until it returnsfalse.app.proxy()returns aSendhandle.proxy.post(|cx| ...)runs a closure on the UI thread, andproxy.send(message)hands a value, by reference, to everyapp.on_messagehandler for its type. Its queue is bounded.- With the
tokiofeature,app.spawn(future, |cx, output| ...)runs a future on a runtime and calls back on the UI thread.
A Timeline plays tweens, callbacks, and other timelines at positions along
it. A Tween moves a value between two ends with one of Core's easings and
hands each new value to a handler:
let fade = Timeline::new(&app).add(
Duration::ZERO,
Tween::new(0.0, 1.0).duration(Duration::from_millis(300)).ease(Easing::OutQuad).on_update(move |_cx, value| {
card.opacity(value as f32);
}),
);
// Later: fade.pause(), play(), restart(), or seek(Duration::from_millis(150)).Tweens move f64, f32, and Color values, or your own types through the
Interpolate trait. They can repeat (loops(3), forever()) with a
loop_delay, and run back and forth with alternate(true). Timelines loop
with looping(true), nest with sync, run call callbacks, and report
on_complete. Without a duration, a timeline ends with its last item, and
an empty one waits: once plays one-off tweens on it as they come.
Timelines advance through app.animate, so they follow the test clock and
request frames only while they play. A playing timeline keeps itself alive
until it finishes; handlers may capture timelines and widgets, and the app
breaks those cycles when it ends.
app.console() is Core's debug console: an overlay, hidden until shown, that
lists messages logged with opentui::console::{info, warn, error, ...} from
any thread. The log feature adds console::init_logger for the log
crate's macros, and AppOptions::capture_stderr sends eprintln! output to
the console while the app runs, then writes it to the real stderr afterwards.
app.set_debug_overlay(Some(Corner::TopRight)) shows native frame statistics.
A frame too large for the Session's output queue is not drawn;
App::on_frame_dropped reports it once, with its size and the limit, as
Core's frame:dropped event does.
The post module ports Core's post-processing: color matrices (sepia,
grayscale, color-blindness simulation and compensation, technicolor, and
more), filters (scanlines, noise, chromatic aberration, ASCII art, bloom), and
animated effects (vignette, distortion, clouds, flames, a CRT rolling bar,
rainbow text). A filter changes a widget's area after each frame is painted,
or the whole screen on app.root():
use opentui::post::{CrtRollingBar, Scanlines, Vignette};
let monitor = View::new(&app).border(BorderStyle::Rounded).grow(1.0);
monitor.filter(ColorMatrix::GREENSCALE).filter(Scanlines::default()).filter(Vignette::new(0.6));
let bar = app.root().add_filter(CrtRollingBar::default()); // animated: frames run until it is removed
app.root().remove_filter(bar);Color matrices run in native code, as do scanlines, noise, the vignette,
clouds, and the rolling bar; the other filters work on the painted cells.
Implement Filter, or use post::from_fn, for your own. Because filters run
on the finished frame, they also change anything drawn over the widget, such
as a dialog. To change colors at a point in paint order instead, call
Painter::color_matrix from a Component; sh run.sh effects dims the page
behind a dialog that way.
opentui::testing::TestApp runs an app without a terminal, with a manual
clock:
let app = TestApp::new(20, 3);
let name = Input::new(&app);
app.root().add(&name);
name.focus();
app.type_text("Ada");
app.press("ctrl+a");
assert_eq!(name.value(), "Ada");
assert_eq!(app.render().row(0).trim_end(), "Ada");render() paints and presents a frame and returns its cells, with their
colors and attributes. Input methods (press, type_text, paste, feed
for raw bytes, click, drag, scroll, hover) dispatch at once, and
advance moves the clock and runs due timers.
App::headless(HeadlessOptions) runs an app without a terminal in real time,
for harnesses that drive it from another thread through App::proxy. run()
paints frames as on a terminal and hands each frame's cells to
on_headless_frame handlers, and clipboard copies, notifications, and title
changes to on_headless_write handlers. See crates/opentui/docs/headless.md.
The framework sits on layers you can use directly:
native: safe owners for the C ABI:Context,Session,Node, frames, paint recordings, offscreen buffers, edit and text buffers, images, and embedded terminals. Every owner keeps its dependencies alive, so drop order never matters. None of the types areSend, because native code checks the owning thread.host: runs one Session on the local terminal: setup, input, resizes, Ctrl-Z, output delivery, and restoration, from onepoll()loop. If the program returns an error, panics, or receives SIGTERM, SIGHUP, or SIGINT, the terminal is restored before the process exits.input: turns terminal bytes into key, mouse, paste, and focus events with the Session's native input parser: UTF-8, legacy and Kitty keys, SGR and X10 mouse, bracketed paste, and focus reports. Terminal replies come out separately. As in Core, a partial sequence whose next byte takes more than 20 ms is ended, so a key that starts like a sequence cannot hold up later input; a split UTF-8 character and a bareESC [, which may start a mouse report, wait for the next read instead. Core's 1,100 parser test vectors check the events made from the native parser's results.term: raw mode, terminal size, signals, and wakeups, on top oflibc.styleandlayout: colors that keep their terminal intent (RGB, palette, or default), attributes, styled spans, and typed flexbox properties.
The native example shows a scene driven by the host alone.
cargo run --release --example bench times whole frames (event handling,
layout, painting, encoding, and output) without a terminal.
scripts/core-bench.ts runs the same scenes on Core's test renderer under Bun
(bun scripts/core-bench.ts). Both load the library built in this checkout,
so most scenes cost about the same; widgets the host paints
itself are faster from Rust. On one Linux x86_64 machine:
| Scene | Rust | Core |
|---|---|---|
| 200x60, 60 text rows, all change | 418 µs | 472-502 µs |
| 200x60, 60 text rows, one changes | 59 µs | 57 µs |
| ScrollBox with 5000 rows, scroll | 437 µs | 450-456 µs |
| Select with 10000 options, move selection | 30 µs | 87 µs |
| Textarea with 2000 lines, type a character | 36 µs | 47 µs |
sh run.sh hello # a centered card with a button and live event reports
sh run.sh showcase # every widget: a form, a list, a live log, a file table with source, and styled text
sh run.sh effects # Core's color matrices, filters, and animated effects on a panel or the whole screen
sh run.sh tasks # a task list you move through and toggle
sh run.sh native # the native layer and host, without the framework
sh run.sh workbench # a native-layer build queue with live progress and a debug pane
sh run.sh terminal # your shell in a terminal widget, beside a notes field
sh run.sh chat # an SSH chat server on 127.0.0.1:2222: run `ssh -p 2222 localhost` per participant
sh run.sh tasks static
cargo run --release --example bench # headless frame timingsOn a recent x86_64 machine, bench measures about 60 µs per frame to change
one line of a 200x60 screen, 40 µs to move a 10,000-option Select, and 0.5
ms to type into a 2,000-line Textarea, including layout, painting, encoding,
and delivering the output to memory.
sh test.sh # format, clippy, tests, and lifecycle checks, with shared and static linking
sh test.sh shared
cargo test --locked --workspace --features opentui/tokio,opentui/tree-sitter
sh scripts/check-targets.sh # from another system: clippy for both macOS targetsOn Windows, run sh test.sh from Git Bash; it checks the shared linkage only.
cargo test also works from any shell.
After a change to opentui.h, run bun run generate:abi in packages/core,
then sh test.sh shared, and fix the crates in the same change.
scripts/check-targets.sh keeps the macOS code compiling while you work
elsewhere. It needs Zig and the targets' standard libraries, or rust-src
without rustup; its header says how it uses them. It does not link or run
anything, so sh test.sh on the platform is still the real check.
CI runs test.sh on Linux and Windows for each pull request that changes the
crates, native code or the ABI generator, check-targets.sh on Linux, and
test.sh on macOS after each merge. rust-toolchain.toml pins the toolchain it
uses.
Widget tests in crates/opentui/tests/ drive headless apps and compare
rendered frames. Native tests drive real Sessions into memory with a virtual
clock. tests/lifecycle.rs runs the lifecycle example on a pseudo-terminal
(ConPTY on Windows), both on a hand-written host loop and on App::run, and
checks that quitting, resizing, returned errors, panics, and captured stderr
each leave the alternate screen; on Windows it also checks that the console
modes are restored. On Unix, scripts/pty-smoke.py adds signals and Ctrl-Z,
and checks that each exit path restores the terminal settings.
crates/opentui/tests/remote.rs plays the transport for apps on remote
terminals, and crates/opentui-ssh/tests/server.rs connects to a real server
with russh's client; one of its tests runs OpenSSH's ssh on a
pseudo-terminal, and is skipped where ssh is missing.