Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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::connect opens 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. The fallback widget 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, where window.orzma.on(name, handler) receives it.
  • The page sends an event with window.orzma.emit(name, payload). Declare the event with Webview::add_event::<T>(name) when you register the page, and read the events that arrived with view.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) answers window.orzma.call(method, params). The page’s params value 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 an Error carrying 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> or Arc<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, and orzma.blur() gives it back to the terminal.
  • Webview::forward_keys lists chords that reach your app even while the page has focus; every other key goes to the page. Replace the list later with view.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-focus shortcut (<Leader>u by 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}` });
  });
}
FunctionWhat 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.