#![cfg(target_arch = "wasm32")]
use {
crate::{
App, config,
input::{Input, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
logger, theme,
},
console_error_panic_hook::set_once as set_panic_hook,
js_sys::Reflect,
ratzilla::{DomBackend, WebRenderer, web_sys::console},
std::{cell::RefCell, rc::Rc},
tokio::sync::Mutex,
wasm_bindgen::closure::Closure,
wasm_bindgen::{JsCast, prelude::*},
wasm_bindgen_futures::{JsFuture, spawn_local},
web_sys::{
self, Clipboard, CompositionEvent, Event, HtmlTextAreaElement, InputEvent, KeyboardEvent,
},
};
#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
set_panic_hook();
spawn_local(async {
if let Err(err) = run().await {
console::error_1(&err);
}
});
Ok(())
}
async fn run() -> Result<(), JsValue> {
config::init().await;
logger::init().await;
let saved_theme = config::get(config::LAST_THEME)
.await
.and_then(|value| value.parse().ok());
let theme_id = saved_theme.unwrap_or(theme::ThemeId::Dark);
theme::set_theme(theme_id);
config::update(config::LAST_THEME, theme_id.as_str()).await;
let backend = DomBackend::new().map_err(to_js_error)?;
let terminal = ratzilla::ratatui::Terminal::new(backend).map_err(to_js_error)?;
let app = Rc::new(Mutex::new(App::new()));
let ime = init_ime_field()?;
let ime_for_keys = ime.clone();
terminal.on_key_event({
let app = app.clone();
move |event| {
if let Err(err) = ime_for_keys.focus() {
console::error_1(&err);
}
if !event.ctrl && !event.alt {
if let ratzilla::event::KeyCode::Char(ch) = event.code {
let ime_active = web_sys::window()
.and_then(|w| w.document())
.and_then(|doc| doc.active_element())
.and_then(|elem| elem.dyn_into::<HtmlTextAreaElement>().ok())
.map(|active| active == *ime_for_keys)
.unwrap_or(false);
if !ime_active {
let app = app.clone();
spawn_local(async move {
dispatch_text(app, ch.to_string());
});
}
return;
}
}
let app = app.clone();
let key_event = event.into();
spawn_local(async move {
let input = Input::Key(key_event);
let mut guard = app.lock().await;
let action = guard.context.consume(&input).await;
let _ = guard.handle_action(action, input).await;
guard.save().await;
});
}
});
start_render_loop(Rc::new(RefCell::new(terminal)), app.clone())?;
attach_ime_listeners(app.clone(), ime);
Ok(())
}
fn to_js_error<E: std::fmt::Display>(err: E) -> JsValue {
JsValue::from_str(&err.to_string())
}
fn init_ime_field() -> Result<Rc<HtmlTextAreaElement>, JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("missing window"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("missing document"))?;
let textarea = document
.create_element("textarea")?
.dyn_into::<HtmlTextAreaElement>()?;
textarea.set_attribute("autocomplete", "off")?;
textarea.set_attribute("autocorrect", "off")?;
textarea.set_attribute("autocapitalize", "none")?;
textarea.set_attribute("spellcheck", "false")?;
textarea.set_attribute("aria-hidden", "true")?;
textarea.set_class_name("glues-ime");
let style = textarea.style();
let _ = style.set_property("position", "fixed");
let _ = style.set_property("top", "0");
let _ = style.set_property("left", "0");
let _ = style.set_property("width", "100vw");
let _ = style.set_property("height", "100vh");
let _ = style.set_property("opacity", "0");
let _ = style.set_property("background", "transparent");
let _ = style.set_property("color", "transparent");
let _ = style.set_property("caret-color", "transparent");
let _ = style.set_property("border", "0");
let _ = style.set_property("resize", "none");
let _ = style.set_property("pointer-events", "none");
let _ = style.set_property("z-index", "10000");
textarea.set_value("");
if let Some(body) = document.body() {
let _ = body.append_child(&textarea);
}
if let Err(err) = textarea.focus() {
console::error_1(&err);
}
Ok(Rc::new(textarea))
}
fn attach_ime_listeners(app: Rc<Mutex<App>>, ime: Rc<HtmlTextAreaElement>) {
ime.set_value("");
let ime_for_input = ime.clone();
let app_for_input = app.clone();
let handle_input = Closure::<dyn FnMut(InputEvent)>::new(move |event: InputEvent| {
event.prevent_default();
if event.is_composing() {
return;
}
let data = event.data().unwrap_or_default();
if !data.is_empty() {
dispatch_text(app_for_input.clone(), data);
} else {
let input_type = event.input_type();
match input_type.as_str() {
"deleteContentBackward" => dispatch_key(app_for_input.clone(), KeyCode::Backspace),
"deleteContentForward" => dispatch_key(app_for_input.clone(), KeyCode::Delete),
_ => {}
}
}
ime_for_input.set_value("");
});
if let Err(err) =
ime.add_event_listener_with_callback("input", handle_input.as_ref().unchecked_ref())
{
console::error_1(&err);
}
handle_input.forget();
if let Some(window) = web_sys::window() {
let ime_focus = ime.clone();
let handle_mouse = Closure::<dyn FnMut(Event)>::new(move |ev: Event| {
if let Err(err) = ime_focus.focus() {
console::error_1(&err);
}
ev.prevent_default();
});
if let Err(err) = window
.add_event_listener_with_callback("mousedown", handle_mouse.as_ref().unchecked_ref())
{
console::error_1(&err);
}
handle_mouse.forget();
let ime_focus = ime.clone();
let handle_focus = Closure::<dyn FnMut(Event)>::new(move |_ev: Event| {
if let Err(err) = ime_focus.focus() {
console::error_1(&err);
}
});
if let Err(err) =
window.add_event_listener_with_callback("focus", handle_focus.as_ref().unchecked_ref())
{
console::error_1(&err);
}
handle_focus.forget();
}
let ime_for_composition = ime.clone();
let app_for_composition = app.clone();
let handle_composition =
Closure::<dyn FnMut(CompositionEvent)>::new(move |event: CompositionEvent| {
let data = event.data().unwrap_or_else(|| ime_for_composition.value());
if !data.is_empty() {
dispatch_text(app_for_composition.clone(), data);
}
ime_for_composition.set_value("");
});
if let Err(err) = ime.add_event_listener_with_callback(
"compositionend",
handle_composition.as_ref().unchecked_ref(),
) {
console::error_1(&err);
}
handle_composition.forget();
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
let document_for_visibility = document.clone();
let ime_focus = ime.clone();
let handle_visibility = Closure::<dyn FnMut(Event)>::new(move |_event: Event| {
if document_for_visibility.hidden() {
return;
}
if let Err(err) = ime_focus.focus() {
console::error_1(&err);
}
});
if let Err(err) = document.add_event_listener_with_callback(
"visibilitychange",
handle_visibility.as_ref().unchecked_ref(),
) {
console::error_1(&err);
}
handle_visibility.forget();
let ime_focus = ime.clone();
let handle_tab = Closure::<dyn FnMut(KeyboardEvent)>::new(move |event: KeyboardEvent| {
if event.key() == "Tab" && !event.ctrl_key() && !event.alt_key() && !event.shift_key() {
let _ = ime_focus.focus();
event.prevent_default();
}
});
if let Err(err) = document.add_event_listener_with_callback_and_bool(
"keydown",
handle_tab.as_ref().unchecked_ref(),
true,
) {
console::error_1(&err);
}
handle_tab.forget();
}
}
fn dispatch_text(app: Rc<Mutex<App>>, text: String) {
if text.is_empty() {
return;
}
spawn_local(async move {
let mut guard = app.lock().await;
let mut should_save = false;
for ch in text.chars() {
let key_event = KeyEvent {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
};
let input = Input::Key(key_event);
let action = guard.context.consume(&input).await;
let quit = guard.handle_action(action, input).await;
should_save = true;
if quit {
break;
}
}
if should_save {
guard.save().await;
}
});
}
fn dispatch_key(app: Rc<Mutex<App>>, key_code: KeyCode) {
spawn_local(async move {
let key_event = KeyEvent {
code: key_code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
};
let input = Input::Key(key_event);
let mut guard = app.lock().await;
let action = guard.context.consume(&input).await;
let _ = guard.handle_action(action, input).await;
guard.save().await;
});
}
pub fn copy_to_clipboard(text: &str) {
if text.is_empty() {
return;
}
let Some(window) = web_sys::window() else {
console::warn_1(&JsValue::from_str(
"[web::copy_to_clipboard] missing window",
));
return;
};
let navigator = window.navigator();
if !Reflect::has(&navigator, &JsValue::from_str("clipboard")).unwrap_or(false) {
console::warn_1(&JsValue::from_str(
"[web::copy_to_clipboard] navigator.clipboard missing (requires https or localhost)",
));
return;
}
let clipboard: Clipboard = navigator.clipboard();
let text = text.to_owned();
spawn_local(async move {
let promise = clipboard.write_text(&text);
if let Err(err) = JsFuture::from(promise).await {
console::warn_1(&JsValue::from_str(
"[web::copy_to_clipboard] clipboard write failed",
));
console::warn_1(&err);
}
});
}
fn start_render_loop(
terminal: Rc<RefCell<ratzilla::ratatui::Terminal<DomBackend>>>,
app: Rc<Mutex<App>>,
) -> Result<(), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("missing window"))?;
let raf: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
let raf_clone = raf.clone();
let terminal = terminal.clone();
let app_for_draw = app.clone();
let window_for_loop = window.clone();
*raf.borrow_mut() = Some(Closure::wrap(Box::new(move || {
if let Ok(mut guard) = app_for_draw.try_lock() {
if let Err(err) = terminal.borrow_mut().draw(|frame| {
guard.draw(frame);
}) {
console::error_1(&JsValue::from_str(&err.to_string()));
}
}
if let Some(callback) = raf_clone.borrow().as_ref() {
if let Err(err) =
window_for_loop.request_animation_frame(callback.as_ref().unchecked_ref())
{
console::error_1(&err);
}
}
}) as Box<dyn FnMut()>));
if let Some(callback) = raf.borrow().as_ref() {
if let Err(err) = window.request_animation_frame(callback.as_ref().unchecked_ref()) {
console::error_1(&err);
}
}
Ok(())
}