#[cfg(target_arch = "wasm32")]
mod web {
use mtui::app::App;
use mtui::config::Config;
use mtui::constants::EVENT_HANDLER_TICKRATE;
use mtui::handler::handle_key_events;
use mtui::input;
use mtui::tui::render;
use mtui::{compat, logger};
use ratatui::Terminal;
use ratzilla::backend::webgl2::WebGl2BackendOptions;
use ratzilla::web_sys;
use ratzilla::web_sys::wasm_bindgen::prelude::Closure;
use ratzilla::web_sys::wasm_bindgen::JsCast;
use ratzilla::{DomBackend, FontAtlasConfig, WebGl2Backend};
use std::cell::{Cell, RefCell};
use std::io;
use std::rc::Rc;
use tokio::sync::Mutex;
const TERMINAL_CONTAINER_ID: &str = "mtui-term";
const ERROR_OVERLAY_ID: &str = "mtui-error";
const DRAW_FAILURES_BEFORE_REBUILD: u32 = 20;
const FRAMES_BETWEEN_REBUILDS: u32 = 60;
fn convert_key(event: ratzilla::event::KeyEvent) -> Option<input::KeyEvent> {
use ratzilla::event::KeyCode;
let code = match event.code {
KeyCode::Char(c) => input::KeyCode::Char(c),
KeyCode::Esc => input::KeyCode::Esc,
KeyCode::Enter => input::KeyCode::Enter,
KeyCode::Backspace => input::KeyCode::Backspace,
KeyCode::Delete => input::KeyCode::Delete,
KeyCode::Tab => input::KeyCode::Tab,
KeyCode::Up => input::KeyCode::Up,
KeyCode::Down => input::KeyCode::Down,
KeyCode::Left => input::KeyCode::Left,
KeyCode::Right => input::KeyCode::Right,
KeyCode::PageUp => input::KeyCode::PageUp,
KeyCode::PageDown => input::KeyCode::PageDown,
_ => return None,
};
Some(input::KeyEvent::new(code))
}
fn console_error(msg: &str) {
web_sys::console::error_1(&msg.into());
}
fn document() -> io::Result<web_sys::Document> {
web_sys::window()
.and_then(|w| w.document())
.ok_or_else(|| io::Error::other("no document"))
}
pub fn show_error_overlay(msg: &str) {
let Ok(document) = document() else { return };
let overlay = match document.get_element_by_id(ERROR_OVERLAY_ID) {
Some(existing) => existing,
None => {
let Ok(element) = document.create_element("pre") else {
return;
};
element.set_id(ERROR_OVERLAY_ID);
let _ = element.set_attribute(
"style",
"position:fixed;top:0;left:0;z-index:10;margin:0;padding:8px;\
max-width:100vw;white-space:pre-wrap;color:#ff5252;\
background:#000c;font:14px monospace",
);
if let Some(body) = document.body() {
let _ = body.append_child(&element);
}
element
}
};
overlay.set_text_content(Some(msg));
console_error(msg);
}
fn hide_error_overlay() {
if let Ok(document) = document() {
if let Some(overlay) = document.get_element_by_id(ERROR_OVERLAY_ID) {
overlay.remove();
}
}
}
enum WebTerminal {
Gl(Box<Terminal<WebGl2Backend>>),
Dom(Box<Terminal<DomBackend>>),
}
impl WebTerminal {
fn draw(&mut self, app: &mut App) -> io::Result<()> {
match self {
WebTerminal::Gl(terminal) => terminal.draw(|frame| render(app, frame)),
WebTerminal::Dom(terminal) => terminal.draw(|frame| render(app, frame)),
}
.map(|_| ())
}
}
fn build_gl_terminal() -> io::Result<Terminal<WebGl2Backend>> {
let options = WebGl2BackendOptions::new()
.grid_id(TERMINAL_CONTAINER_ID)
.font_atlas_config(FontAtlasConfig::dynamic(&["monospace"], 16.0))
.disable_auto_css_resize();
let backend = WebGl2Backend::new_with_options(options)
.map_err(|e| io::Error::other(e.to_string()))?;
Terminal::new(backend)
}
fn build_dom_terminal() -> io::Result<Terminal<DomBackend>> {
let backend = DomBackend::new_by_id(TERMINAL_CONTAINER_ID)
.map_err(|e| io::Error::other(e.to_string()))?;
Terminal::new(backend)
}
fn ensure_terminal_container() -> io::Result<()> {
let document = document()?;
if document.get_element_by_id(TERMINAL_CONTAINER_ID).is_none() {
let container = document
.create_element("div")
.map_err(|e| io::Error::other(format!("{e:?}")))?;
container.set_id(TERMINAL_CONTAINER_ID);
let _ = container.set_attribute("style", "width:100%;height:100%");
document
.body()
.ok_or_else(|| io::Error::other("no body"))?
.append_child(&container)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
Ok(())
}
fn clear_terminal_container() {
if let Ok(document) = document() {
if let Some(container) = document.get_element_by_id(TERMINAL_CONTAINER_ID) {
container.set_inner_html("");
}
}
}
fn watch_context_loss(gl_lost: &Rc<Cell<bool>>) {
let Ok(document) = document() else { return };
let Some(container) = document.get_element_by_id(TERMINAL_CONTAINER_ID) else {
return;
};
let Ok(Some(canvas)) = container.query_selector("canvas") else {
return;
};
let gl_lost = gl_lost.clone();
let on_lost = Closure::<dyn FnMut()>::new(move || {
console_error("mtui: WebGL context lost; rebuilding the renderer");
gl_lost.set(true);
});
let _ = canvas
.add_event_listener_with_callback("webglcontextlost", on_lost.as_ref().unchecked_ref());
on_lost.forget();
}
fn build_web_terminal(gl_lost: &Rc<Cell<bool>>) -> io::Result<WebTerminal> {
ensure_terminal_container()?;
clear_terminal_container();
match build_gl_terminal() {
Ok(terminal) => {
watch_context_loss(gl_lost);
Ok(WebTerminal::Gl(Box::new(terminal)))
}
Err(gl_error) => {
console_error(&format!(
"mtui: WebGL2 renderer unavailable ({gl_error}); \
falling back to the slower DOM renderer"
));
clear_terminal_container();
build_dom_terminal().map(|t| WebTerminal::Dom(Box::new(t)))
}
}
}
fn inner_size(window: &web_sys::Window) -> (i32, i32) {
let px = |v: Result<web_sys::wasm_bindgen::JsValue, _>| {
v.ok().and_then(|v| v.as_f64()).unwrap_or(0.0) as i32
};
(px(window.inner_width()), px(window.inner_height()))
}
fn start_render_loop(
window: web_sys::Window,
app: Rc<Mutex<App>>,
dirty: Rc<Cell<bool>>,
) -> io::Result<()> {
let gl_lost = Rc::new(Cell::new(false));
let mut terminal = build_web_terminal(&gl_lost)?;
let mut last_seen = inner_size(&window);
let mut last_tick = compat::Instant::now();
let mut draw_failures: u32 = 0;
let mut frames_since_rebuild: u32 = 0;
type FrameCallback = Rc<RefCell<Option<Closure<dyn FnMut()>>>>;
let callback: FrameCallback = Rc::new(RefCell::new(None));
let schedule: Rc<dyn Fn()> = Rc::new({
let callback = callback.clone();
let window = window.clone();
move || {
if let Some(callback) = callback.borrow().as_ref() {
let _ = window.request_animation_frame(callback.as_ref().unchecked_ref());
}
}
});
*callback.borrow_mut() = Some(Closure::new({
let schedule = schedule.clone();
move || {
schedule();
if gl_lost.get() {
frames_since_rebuild += 1;
if frames_since_rebuild < FRAMES_BETWEEN_REBUILDS {
return;
}
frames_since_rebuild = 0;
match build_web_terminal(&gl_lost) {
Ok(rebuilt) => {
terminal = rebuilt;
gl_lost.set(false);
draw_failures = 0;
hide_error_overlay();
dirty.set(true);
}
Err(e) => {
show_error_overlay(&format!(
"mtui: the renderer died and could not be rebuilt: {e}"
));
return;
}
}
}
let Ok(mut app) = app.try_lock() else {
return;
};
let mut must_draw = dirty.replace(false);
let size = inner_size(&window);
if size != last_seen {
last_seen = size;
dirty.set(true);
must_draw = true;
}
if last_tick.elapsed() >= EVENT_HANDLER_TICKRATE {
last_tick = compat::Instant::now();
futures::executor::block_on(app.tick());
must_draw = true;
}
if must_draw {
let started = compat::Instant::now();
match terminal.draw(&mut app) {
Ok(()) => draw_failures = 0,
Err(e) => {
draw_failures += 1;
if draw_failures == 1 {
console_error(&format!("mtui: terminal draw failed: {e}"));
}
if draw_failures >= DRAW_FAILURES_BEFORE_REBUILD {
gl_lost.set(true);
draw_failures = 0;
}
}
}
app.last_frame = started.elapsed();
}
}
}));
schedule();
Ok(())
}
pub fn run() -> io::Result<()> {
console_error_panic_hook::set_once();
logger::init();
let app = Rc::new(Mutex::new(futures::executor::block_on(App::boot(
Config::demo(),
"browser demo (not persisted)".to_string(),
))));
let window = web_sys::window().ok_or_else(|| io::Error::other("no window"))?;
let dirty = Rc::new(Cell::new(true));
let key_app = app.clone();
let key_dirty = dirty.clone();
let on_key = Closure::<dyn FnMut(_)>::new(move |event: web_sys::KeyboardEvent| {
if event.ctrl_key() || event.alt_key() || event.meta_key() {
return;
}
let Some(key) = convert_key(event.clone().into()) else {
return;
};
event.prevent_default();
let app = key_app.clone();
let dirty = key_dirty.clone();
wasm_bindgen_futures::spawn_local(async move {
let mut app = app.lock().await;
let _ = handle_key_events(key, &mut app).await;
dirty.set(true);
});
});
window
.add_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref())
.map_err(|e| io::Error::other(format!("{e:?}")))?;
on_key.forget();
start_render_loop(window, app, dirty)
}
}
#[cfg(target_arch = "wasm32")]
fn main() {
if let Err(e) = web::run() {
web::show_error_overlay(&format!("mtui: the browser demo failed to start: {e}"));
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("mtui-web targets the browser; build it with `trunk build`.");
}