Building Webview Apps
This page walks through writing a terminal app that shows a web page, with
ratatui_orzma, the Rust SDK, and through
having the page talk back with
@orzma/web. Each step is a
complete example from the repository.
How it fits together
flowchart LR
program["Your program<br/>(ratatui + ratatui_orzma)"]
orzma(["orzma"])
page["Web page<br/>(window.orzma)"]
program -->|"register content and place it"| orzma
orzma -->|"draws the page in the program's cells"| page
program <-->|"calls and events"| orzma
orzma <-->|"calls and events"| page
Your program runs inside an orzma pane and draws its interface with ratatui, leaving a rectangle of cells for the page. The SDK registers the page with orzma, places it wherever the widget is drawn, and carries messages between your program and the page.
Setup
Add the SDK, the version of ratatui it is built against, and serde:
cargo add ratatui_orzma ratatui@0.29
cargo add serde --features derive
Your app must use the same ratatui version as ratatui_orzma, which is built
against ratatui 0.29; with another version, the SDK’s widget and backend types
do not match yours. The examples use let chains, which need Rust 1.88 or later
and the 2024 edition.
The app has to run inside an orzma pane. orzma sets ORZMA_SOCK and
ORZMA_TOKEN in every pane, and Orzma::connect returns an error when they
are missing.
The examples below live in
sdk/ratatui_orzma/examples.
To run one as it is, clone the repository and run
cargo run -p ratatui_orzma --example <name> inside an orzma pane.
Step 1: Show a page
simple registers a small HTML document and draws it below a one-line hint.
//! Minimal orzma webview render. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example simple`.
//!
//! Registers a tiny HTML page (`simple.html`, embedded via `include_str!`) and
//! renders it as a ratatui widget filling the pane below a one-line hint. This is
//! the whole render path: connect → register → draw. Press `q` to quit.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::Duration;
const HTML: &str = include_str!("simple.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML))?;
common::run(&orzma, |terminal| {
loop {
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(Paragraph::new("simple webview · q to quit"), rows[0]);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#13131a;color:#8be9fd;font:20px sans-serif">
Hello from an orzma webview
</body>
Orzma::connectopens the connection to orzma. Call it once at startup.orzma.register(Webview::inline(HTML))registers the page and returns a handle. Registering waits for orzma’s reply, so do it before the draw loop.WebviewWidget::new(view.instance_id())marks where the page goes. Render it with&mut *orzma.frame()as its state, like any stateful ratatui widget. Thefallbackwidget is drawn in the same cells, and orzma draws cell text over the page, so the fallback stays visible after the page appears.
The terminal setup that the examples share wraps the crossterm backend in
OrzmaBackend. On every draw, the backend tells orzma where each page is, so
the page follows your layout:
//! Shared terminal setup/teardown for the ratatui_orzma examples.
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui_orzma::{Orzma, OrzmaBackend};
use std::error::Error;
use std::io::{Stdout, stdout};
/// The concrete backend the examples draw through: orzma wrapping crossterm.
pub(crate) type Backend = OrzmaBackend<CrosstermBackend<Stdout>>;
/// Runs `body` with a live orzma-backed terminal, restoring the terminal on exit.
pub(crate) fn run<F>(orzma: &Orzma, body: F) -> Result<(), Box<dyn Error>>
where
F: FnOnce(&mut Terminal<Backend>) -> Result<(), Box<dyn Error>>,
{
enable_raw_mode()?;
let _guard = TerminalGuard;
execute!(stdout(), EnterAlternateScreen)?;
let backend = OrzmaBackend::new(CrosstermBackend::new(stdout()), orzma);
let mut terminal = Terminal::new(backend)?;
body(&mut terminal)
}
/// Restores the terminal (raw mode off, leave alternate screen) on drop, so
/// teardown runs unconditionally — including when a fallible setup step or `body`
/// errors or panics.
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen);
}
}
Webview::inline serves one HTML document. Webview::dir(root, entry) serves
a directory of files, such as a bundled frontend, from an absolute root path,
and Webview::url(url) loads a remote http or https page.
Step 2: Exchange events
events sends a counter to the page every second, and the page sends a message
back every second.
//! Event round-trip between the app and a webview (no call/reply — see the `rpc` example — and no focus).
//! Run inside an orzma pane: `cargo run -p ratatui_orzma --example events`.
//!
//! Two one-way event channels form a loop:
//! - app → page: the app emits a `tick` counter each second; the page's
//! `window.orzma.on('tick', …)` shows it.
//! - page → app: the page's `setInterval` calls `window.orzma.emit('hello', …)`;
//! the app drains `view.read_events::<Hello>()` into its status line.
//!
//! No keyboard focus is involved — the page's JS, `window.orzma.on`, and
//! `window.orzma.emit` all run regardless of focus, so the app keeps the keyboard
//! and `q` quits immediately. The widget is still rendered every frame: that is what
//! keeps the page MOUNTED, and both `emit` directions are mount-scoped (a no-op when
//! nothing is mounted).
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::{Duration, Instant};
#[derive(serde::Deserialize)]
struct Hello {
message: String,
}
const HTML: &str = include_str!("events.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML).add_event::<Hello>("hello"))?;
let mut last_msg = String::from("(none yet)");
let mut n: u64 = 0;
let mut last_tick = Instant::now();
common::run(&orzma, |terminal| {
loop {
for Hello { message } in view.read_events::<Hello>() {
last_msg = message;
}
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!("events · q to quit · last: {last_msg}")),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if last_tick.elapsed() >= Duration::from_secs(1) {
n += 1;
let _ = view.emit("tick", &n);
last_tick = Instant::now();
}
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;padding:10px;background:#13131a;color:#8be9fd;font:14px sans-serif">
<div id="tick">waiting for tick…</div>
<script>
window.orzma.on('tick', (n) => {
document.getElementById('tick').textContent = `tick #${n}`;
});
let i = 0;
setInterval(() => {
i += 1;
window.orzma.emit('hello', { message: `from page #${i}` });
}, 1000);
</script>
</body>
view.emit(name, &payload)sends an event to the page, wherewindow.orzma.on(name, handler)receives it.- The page sends an event with
window.orzma.emit(name, payload). Declare the event withWebview::add_event::<T>(name)when you register the page, and read the events that arrived withview.read_events::<T>()in your loop. - Events are delivered only while the page is on screen, so keep rendering the widget.
Step 3: Handle calls from the page
rpc answers two methods that the page calls: add returns a sum, and
divide fails when the divisor is zero.
//! Call/reply RPC between a webview and the app. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example rpc`.
//!
//! The page calls two app methods through `window.orzma.call` once a second:
//! - `add` resolves with the sum of its two operands;
//! - `divide` rejects with an `RpcError` when the divisor is zero, which the
//! page receives as a rejected Promise.
//!
//! Handlers run on the SDK's reader thread, not in the draw loop, so the count
//! of answered calls that the status line shows is shared through an atomic.
//! The view is registered with `.interactive(false)`: a click on the page never
//! takes keyboard focus, so `q` always reaches the app.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, RpcError, Webview, WebviewWidget};
use std::error::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
#[derive(serde::Deserialize)]
struct Operands {
a: f64,
b: f64,
}
const HTML: &str = include_str!("rpc.html");
fn main() -> Result<(), Box<dyn Error>> {
let answered = Arc::new(AtomicU64::new(0));
let add_answered = Arc::clone(&answered);
let add = move |Operands { a, b }: Operands| -> Result<f64, RpcError> {
add_answered.fetch_add(1, Ordering::Relaxed);
Ok(a + b)
};
let divide_answered = Arc::clone(&answered);
let divide = move |Operands { a, b }: Operands| -> Result<f64, RpcError> {
divide_answered.fetch_add(1, Ordering::Relaxed);
if b == 0.0 {
return Err(RpcError::new("division by zero"));
}
Ok(a / b)
};
let orzma = Orzma::connect()?;
let view = orzma.register(
Webview::inline(HTML)
.interactive(false)
.on("add", add)
.on("divide", divide),
)?;
common::run(&orzma, |terminal| {
loop {
let calls = answered.load(Ordering::Relaxed);
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!("rpc · q to quit · calls answered: {calls}")),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;padding:10px;background:#13131a;color:#8be9fd;font:14px sans-serif">
<div id="sum">add: waiting…</div>
<div id="quotient">divide: waiting…</div>
<script>
const sum = document.getElementById('sum');
const quotient = document.getElementById('quotient');
let n = 0;
const tick = () => {
n += 1;
const a = n;
window.orzma.call('add', { a, b: 2 }).then(
(value) => {
sum.textContent = `add(${a}, 2) = ${value}`;
},
(error) => {
sum.textContent = `add(${a}, 2) failed: ${error.message}`;
},
);
const b = a % 3;
window.orzma.call('divide', { a, b }).then(
(value) => {
quotient.textContent = `divide(${a}, ${b}) = ${value}`;
},
(error) => {
quotient.textContent = `divide(${a}, ${b}) failed: ${error.message}`;
},
);
};
tick();
setInterval(tick, 1000);
</script>
</body>
Webview::on(method, handler)answerswindow.orzma.call(method, params). The page’sparamsvalue is deserialized into the handler’s argument type, and the handler’s return value is what the page’s Promise resolves with.- Returning
Err(RpcError::new(message))rejects the page’s Promise with anErrorcarrying that message. - Handlers run on the SDK’s background thread, not in your draw loop. Share
state with the rest of your app through types such as
Arc<AtomicU64>orArc<Mutex<T>>, and keep handlers short. .interactive(false)registers a page that takes no mouse or keyboard input, so a click on it never takes the keyboard away from your app.
Step 4: Share the keyboard
A click on an interactive page gives it keyboard focus: from then on, keys go
to the page, not to your app. forward_keys shows how an app hands the
keyboard to the page and takes it back.
//! Forwarding keys out of a focused webview. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example forward_keys`.
//!
//! A webview plus a native status line, with app-owned focus in a `web_focused`
//! bool. `Alt+l` focuses the webview (bare keys then type into its input); `Alt+h`
//! returns focus to the app; `q` quits while the app is focused.
//!
//! Only `Alt+h` is declared as a forward-key, and that asymmetry is the point:
//! `Alt+h` is pressed WHILE the page holds keyboard focus, so without forwarding it
//! would be swallowed by the page and focus could never leave the webview — the host
//! forwards the declared chord to the app, and only to the app, so `event::read`
//! sees it even while the page is focused. `Alt+l` is pressed while the app still
//! owns the keyboard, so it already reaches `event::read` and needs no declaration.
//!
//! `WebviewHandle::focus` and `Orzma::blur` send the control-plane focus op, and
//! `WebviewHandle::read_focus_changes` reports a click that focused the page.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{KeyChord, Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::Duration;
const HTML: &str = include_str!("forward_keys.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML).forward_keys([KeyChord {
mods: KeyModifiers::ALT,
code: KeyCode::Char('h'),
}]))?;
let mut web_focused = false;
common::run(&orzma, |terminal| {
loop {
for change in view.read_focus_changes() {
web_focused = change.focused;
}
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!(
"Alt+l focus webview · Alt+h leave · q quit · focus: {}",
if web_focused { "webview" } else { "app" }
)),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("webview")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
{
match (k.modifiers, k.code) {
(KeyModifiers::ALT, KeyCode::Char('l')) => {
view.focus()?;
}
(KeyModifiers::ALT, KeyCode::Char('h')) => {
orzma.blur()?;
}
(KeyModifiers::NONE, KeyCode::Char('q')) if !web_focused => return Ok(()),
_ => {}
}
}
}
})
}
<body style="margin:0;height:100vh;box-sizing:border-box;background:#10121a;color:#8be9fd;font:14px sans-serif;display:flex;flex-direction:column;gap:8px;padding:10px">
<div>type here — bare keys reach the focused webview:</div>
<input id="in" placeholder="..." style="font:14px monospace;padding:6px;background:#1b1e2b;color:#e7e7ef;border:1px solid #8be9fd;border-radius:4px">
<div style="opacity:.7">Alt+h returns focus to the app</div>
<script>
var i = document.getElementById('in');
i.focus();
window.addEventListener('focus', function () {
i.focus();
});
</script>
</body>
view.focus()gives the page keyboard focus, andorzma.blur()gives it back to the terminal.Webview::forward_keyslists chords that reach your app even while the page has focus; every other key goes to the page. Replace the list later withview.set_forward_keys.view.read_focus_changes()reports every focus change, including a click on the page.- Users can always take the keyboard back with the
release-webview-focusshortcut (<Leader>uby default; see Key Bindings).
The page side
orzma injects window.orzma into every page registered with Webview::inline
or Webview::dir. A Webview::url page gets it only when the app opts in with
.bridge(true), or registers a handler with on or an event with add_event.
For TypeScript, @orzma/web adds types to the bridge:
npm install @orzma/web
import { isOrzmaAvailable, orzma } from '@orzma/web';
if (isOrzmaAvailable()) {
orzma.on<number>('tick', (n) => {
document.title = `tick ${n}`;
});
orzma.call<number>('add', { a: 1, b: 2 }).then((sum) => {
orzma.emit('hello', { message: `1 + 2 = ${sum}` });
});
}
| Function | What it does |
|---|---|
orzma.call<R>(method, params?) | Calls a method in the app and resolves with its reply; rejects with an Error when the app returns an error. There is no timeout. |
orzma.on<P>(event, handler) | Runs handler for every event with that name from the app. |
orzma.off<P>(event, handler) | Removes a handler added with on. |
orzma.emit<P>(event, payload?) | Sends a one-way event to the app. |
isOrzmaAvailable() | Reports whether the page has the bridge. |
Next steps
- The full API is on docs.rs.
- The Webview Protocol page describes the wire protocol, for writing a client in another language.