Documentação
Your first app
Building a native TDE app from scratch.
Nesta página
What a TDE app actually is
A TDE app is an ordinary ANSI terminal program. It is its own Rust binary, it runs as its own OS process, and TDE hosts it behind a PTY in a desktop window. The same binary runs identically in a plain terminal — that is the test that it is a real app and not a plugin.
What TDE owns: windows, focus, composition, the PTY and the app-host lifecycle.
What your app never receives: Desktop, Wm, or any global TDE state object.
The boundary is a process boundary, and it exists so that an app crash — or an
app's dependency tree — stays out of the desktop process.
Apps build their interface from tde-app-sdk, which re-exports the shared
component kit. That is how a third-party app looks native, follows the user's
theme and works in Web Share without knowing any of that.
Quick start
Install the author CLI, scaffold, test, build and register:
cargo install --git https://github.com/tde-sh/tde --package tde-app-sdk --bin tde-app
tde-app new ./my-app dev.example.my-app
cd my-app
tde-app validate tde-app.toml
cargo fmt --check
cargo test
cargo run
cargo build --release
tde-app registercargo run starts the binary in your current terminal — no desktop needed.
tde-app register writes a Launcher command extension pointing at the release
binary; restart TDE and search for the app's title in the Launcher.
The scaffold is deliberately small: Cargo.toml, tde-app.toml, src/lib.rs
with a working App, src/main.rs, a headless test in tests/, and a
TDE_APP.md written for coding agents. The id argument is optional — omit it and
tde-app derives dev.tde.<dirname>.
| Command | What it does |
|---|---|
tde-app new <dir> [id] | Scaffold a project. Refuses a non-empty directory. |
tde-app validate [tde-app.toml] | Check schema, id, entrypoint and capability names. |
tde-app register [tde-app.toml] [--commands-dir <dir>] | Write the Launcher entry. Fails if the release binary is missing. |
The manifest
tde-app.toml is the app's portable declaration.
schema = 1
[app]
id = "dev.example.task-board"
title = "Task Board"
version = "0.1.0"
entrypoint = "target/release/task-board"
min_tde = "0.3.18"
capabilities = []| Field | Rules |
|---|---|
schema | Must be 1. manifest_version is accepted as a synonym; if both are present they must agree. |
app.id | Lowercase reverse-DNS, path-safe, stable. Must match AppMetadata. |
app.title | Visible text, no control characters. |
app.version | Starts with a digit; digits and . - + * ^ only. Must match AppMetadata. |
app.entrypoint | A safe relative path. No absolute paths, no .., no backslashes, no whitespace or shell metacharacters. |
app.min_tde | Same version grammar. Raise it only when your code needs a newer host. |
capabilities | Any of storage, network, clipboard, process, secrets. Unknown names are a validation error. |
Unknown keys are rejected outright, so a typo fails tde-app validate instead of
being silently ignored.
Registration writes ~/.config/tde/commands/<app-id>.toml containing a single
Open command that changes into the project directory and executes the release
binary. It is the same command extension
mechanism you could write by hand — registration is not a Store publication.
The lifecycle
The v0.1 trait is intentionally three methods:
pub trait App {
fn metadata(&self) -> AppMetadata;
fn update(&mut self, event: Event) -> AppAction;
fn render(&mut self, frame: &mut Frame<'_>);
}| Type | Your responsibility |
|---|---|
AppMetadata | Built with AppMetadata::new(id, title, version, icon); it validates all four. |
Event | Key, Mouse, Resize, Tick, FocusGained, FocusLost, CloseRequested. |
Frame | area(), theme() and paint() — the bounded surface you are allowed to draw on. |
AppAction | Return Continue, Quit, or Notify(Notice). Never reach into host state. |
AppHarness | Drives events and inspects the canvas with no terminal attached. |
Import surface:
use tde_app_sdk::{App, AppAction, AppMetadata, Event, Frame};
use tde_app_sdk::ui::prelude::*;Direct imports from tde, tde-wm, tde-term or tde-app-host are not public
API and will break. Pin the SDK to a tag or revision and set a matching
min_tde; the desktop is free to change its compositor and PTY host underneath
you as long as you stay on the published boundary.
UI rules
Four rules carry most of the weight.
Theme-native styling
Use the semantic styles a Theme exposes — base, heading, muted, field,
selected, accent_style — and the component tokens. No RGB literals, no
private app palette, no if theme_is_dark ladder. The host can switch to e-Ink
or any of the other palettes without your app being rewritten.
Rect clipping is mandatory
Canvas::draw_str and Canvas::fill know only the outer canvas edge. They do
not know about your window, and they will happily paint over the desktop.
- Use
frame.paint()for every piece of app-specific text, fill and ellipsis. It exposes only bounded operations. - Pass
frame.canvas()only into a shared component'srendermethod. - Give the same
arearectangle to layout, paint, placement and hit-testing.
This is not advisory. Every TDE app crate ships an AST scanner in
tests/ui2_regression.rs that parses its own sources and fails the build on a
direct Canvas mutator — resolving aliases, reborrows and shadowing rather than
grepping for a method name.
Responsive, not a second app
Observe frame.area() at the top of render and check the Breakpoint. At
compact width, swap a permanent rail and split panes for AppBar plus
NavDrawer, ellipsize long labels, and choose a compact table representation.
The supported minimum viewport is 30 × 24 cells. Do not build a separate
mobile app: same task, different presentation.
Mouse first, keyboard complete
Every interactive target starts as a visible mouse or tap target, and every one of them also needs visible focus and a keyboard route:
| Intent | Required binding |
|---|---|
| Move between controls | Arrow keys |
| Activate, toggle, submit | Enter and Space |
| Close, cancel, go back | Esc, before the host sees a close request |
| Pointer | The same action and geometry as the keyboard focus |
Tab / Shift+Tab may assist form traversal but cannot be the only path.
Single-letter accelerators are fine as a shortcut, never as the only route. Keep
one source of geometry shared by render, hit-testing and focus.
Headless tests
AppHarness is the public test seam. Test the state transition first, then
render it at a fixed size:
let mut app = AppHarness::new(MyApp::default(), Size::new(44, 10), Theme::tde());
app.dispatch(Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
app.render();
assert_eq!(app.app().runs(), 1);The harness offers new, dispatch, render, resize, app, canvas and
notification inspection — no terminal, no screenshots, no flake. Cover at least:
- a normal width and a compact width;
- the important state transition;
- a click inside the visible control rect;
- the
Enter/Spaceequivalent of that click; Esccancel or close;- an empty data state;
- a long Unicode label that must ellipsize.
Do not mock a component's result. Exercise real app state and the actual canvas.
Before you share it
tde-app validate tde-app.tomlpasses.cargo fmt --checkandcargo testpass.- Headless tests exist for the important transitions and the compact layout.
tde-app.tomldeclares the fewest capabilities possible and matchesAppMetadata.- No private desktop crate, no raw host protocol, no hard-coded colour, no unclipped write.
cargo build --release && tde-app registersucceeds, TDE restarts, and the app opens from the Launcher.
Related
- Design system — the component vocabulary and the rules behind these constraints.
- Extending without code — when a TOML file is enough.
- Store overview — how apps reach other people.
- Apps overview — what TDE's own apps do, as reference implementations.