retroglyph_terminal_wasm/app_entry.rs
1//! `app_entry!`'s own doc comment (below) is what renders publicly: `#[macro_export]` lifts a
2//! macro to the crate root regardless of the declaring module's visibility, and this module is
3//! deliberately kept private, the same pattern `examples/src/wasm_entry.rs` uses for its own
4//! `Example`-based equivalents. Nothing here needs its own rustdoc page.
5
6/// Emits the `wasm-bindgen` FFI surface driving `$A: App<TerminalWasm> + Default` from a browser
7/// terminal emulator (e.g. xterm.js), on `wasm32` only.
8///
9/// `examples/src/wasm_entry.rs`'s `__wasm_terminal_entry!` does the same job for the examples
10/// crate's private `Example` trait, but that crate is `publish = false`, so nothing outside this
11/// repo can reach it (retroglyph#684). This macro is the generally-usable version: generic over
12/// [`App`](retroglyph_core::app::App) (public, stable, and already the update contract every other
13/// driver in `retroglyph-core` shares), not `Example`.
14///
15/// Call it once, at the top level of a `wasm32` binary crate that depends on this crate and
16/// `retroglyph-core`:
17///
18/// ```ignore
19/// #[derive(Default)]
20/// struct MyGame { /* ... */ }
21///
22/// impl retroglyph_core::app::App<retroglyph_terminal_wasm::TerminalWasm> for MyGame {
23/// fn update(
24/// &mut self,
25/// term: &mut retroglyph_core::terminal::Terminal<retroglyph_terminal_wasm::TerminalWasm>,
26/// frame: &retroglyph_core::app::Frame,
27/// ) -> retroglyph_core::app::Flow {
28/// // ...
29/// retroglyph_core::app::Flow::Continue
30/// }
31/// }
32///
33/// retroglyph_terminal_wasm::app_entry!(MyGame);
34///
35/// fn main() {}
36/// ```
37///
38/// Expands to nothing at all off `wasm32` (a native build of the same crate just doesn't get this
39/// FFI surface, since nothing would call it).
40///
41/// Exports, all thread-local and single-instance (one `$A` per page; construct a fresh
42/// handle-based session per instance instead via this crate's `wasm` module, only compiled for
43/// `target_arch = "wasm32"`, if a page needs more than one):
44///
45/// - `wasm_app_init(width, height)`: builds the `Terminal<TerminalWasm>` at the given size (in
46/// cells) and `$A::default()`. Call once, before the first tick, after sizing the host terminal
47/// emulator (e.g. xterm.js's `fitAddon.fit()`).
48/// - `wasm_app_resize(width, height)`: reports a new size (in cells) via
49/// [`resize_terminal`](crate::resize_terminal), so the driven `$A` sees the matching
50/// `Event::Resize` on its next `update`, not just a backend that silently changed size under it.
51/// - `wasm_app_push_key(code, mods)` / `wasm_app_push_mouse(x, y, action, button, mods)`: decode
52/// and queue input via [`decode_key_event`](crate::decode_key_event)/
53/// [`decode_mouse_event`](crate::decode_mouse_event).
54/// - `wasm_app_push_paste(text)`: queues `text` as a single `Event::Paste`.
55/// - `wasm_app_push_focus(focused)`: queues `Event::FocusGained`/`Event::FocusLost`.
56/// - `wasm_app_tick() -> String`: runs one `App::update`, presents unless it returned
57/// `Flow::Idle` (or already presented itself), and returns the ANSI bytes rendered since the
58/// last call, the same contract [`TerminalWasm::take_output`](crate::TerminalWasm::take_output)
59/// documents. `Frame::delta` is wall-clock time since the previous tick, clamped to
60/// `MAX_TICK_DELTA` (250ms): a backgrounded tab can starve `requestAnimationFrame` for seconds
61/// or minutes, and an uncapped delta handed straight to an animation/physics step would try to
62/// simulate that entire gap in one frame (the same "spiral of death" concern
63/// [`FrameClock`](retroglyph_core::frames::FrameClock) caps steps-per-frame to avoid), just on the raw
64/// delta feeding into `Frame` instead. All FFI functions are no-ops (returning an empty string
65/// for `wasm_app_tick`) if called before `wasm_app_init`.
66/// - `wasm_app_exited() -> bool`: `true` once `$A::update` has returned `Flow::Exit` at least
67/// once. A browser tab has no native "exit the process" the way a windowed backend's event loop
68/// does, so this crate can't stop JS's `requestAnimationFrame` loop for it; check this after
69/// `wasm_app_tick` and stop calling it once it flips `true`, e.g. to show a fixed "Game Over"
70/// frame's own draw already put on screen. `wasm_app_tick` keeps calling `$A::update` (and,
71/// correctly, doing nothing useful) if the caller ignores this rather than panicking or hanging.
72#[macro_export]
73macro_rules! app_entry {
74 ($A:ty) => {
75 #[cfg(target_arch = "wasm32")]
76 const _: () = {
77 /// A backgrounded tab can starve `requestAnimationFrame` for an arbitrary amount of
78 /// wall-clock time; capping the delta handed to `App::update` keeps one resumed frame
79 /// from asking an animation/physics step to simulate that entire gap at once. See
80 /// this macro's own doc comment for the full rationale.
81 const MAX_TICK_DELTA: ::core::time::Duration = ::core::time::Duration::from_millis(250);
82
83 struct __RgWasmAppState {
84 term: ::retroglyph_core::terminal::Terminal<$crate::TerminalWasm>,
85 app: $A,
86 last_tick: ::web_time::Instant,
87 frame_count: u64,
88 exited: bool,
89 }
90
91 ::std::thread_local! {
92 static __RG_WASM_APP: ::std::cell::RefCell<::std::option::Option<__RgWasmAppState>> =
93 ::std::cell::RefCell::new(::std::option::Option::None);
94 }
95
96 /// Build the `Terminal<TerminalWasm>` and `$A::default()`. Call before the first
97 /// `wasm_app_tick`.
98 #[::wasm_bindgen::prelude::wasm_bindgen]
99 #[allow(missing_docs)]
100 pub fn wasm_app_init(width: u16, height: u16) {
101 ::console_error_panic_hook::set_once();
102 let mut backend = $crate::TerminalWasm::new(width, height);
103 ::retroglyph_core::backend::Cursor::set_cursor_visible(&mut backend, false);
104 let term = ::retroglyph_core::terminal::Terminal::new(backend);
105 __RG_WASM_APP.with(|cell| {
106 *cell.borrow_mut() = ::std::option::Option::Some(__RgWasmAppState {
107 term,
108 app: <$A as ::std::default::Default>::default(),
109 last_tick: ::web_time::Instant::now(),
110 frame_count: 0,
111 exited: false,
112 });
113 });
114 }
115
116 /// Report a new size (in cells), e.g. after the host terminal emulator re-fits on a
117 /// window resize. No-op if called before `wasm_app_init`.
118 #[::wasm_bindgen::prelude::wasm_bindgen]
119 #[allow(missing_docs)]
120 pub fn wasm_app_resize(width: u16, height: u16) {
121 __RG_WASM_APP.with(|cell| {
122 if let ::std::option::Option::Some(s) = cell.borrow_mut().as_mut() {
123 $crate::resize_terminal(&mut s.term, width, height);
124 }
125 });
126 }
127
128 /// Decode and queue a key event via [`decode_key_event`](crate::decode_key_event).
129 #[::wasm_bindgen::prelude::wasm_bindgen]
130 #[allow(missing_docs)]
131 pub fn wasm_app_push_key(code: u32, mods: u8) {
132 let Some(event) = $crate::decode_key_event(code, mods) else {
133 return;
134 };
135 __RG_WASM_APP.with(|cell| {
136 if let ::std::option::Option::Some(s) = cell.borrow_mut().as_mut() {
137 ::retroglyph_core::backend::Input::push_event(
138 s.term.backend_mut(),
139 ::retroglyph_core::event::Event::Key(event),
140 );
141 }
142 });
143 }
144
145 /// Decode and queue a pointer (mouse/touch) event via
146 /// [`decode_mouse_event`](crate::decode_mouse_event).
147 #[::wasm_bindgen::prelude::wasm_bindgen]
148 #[allow(missing_docs)]
149 pub fn wasm_app_push_mouse(x: u16, y: u16, action: u8, button: u8, mods: u8) {
150 let Some(event) = $crate::decode_mouse_event(x, y, action, button, mods) else {
151 return;
152 };
153 __RG_WASM_APP.with(|cell| {
154 if let ::std::option::Option::Some(s) = cell.borrow_mut().as_mut() {
155 ::retroglyph_core::backend::Input::push_event(
156 s.term.backend_mut(),
157 ::retroglyph_core::event::Event::Mouse(event),
158 );
159 }
160 });
161 }
162
163 /// Queue pasted text as a single `Event::Paste`, not one `Event::Key` per character.
164 #[::wasm_bindgen::prelude::wasm_bindgen]
165 #[allow(missing_docs)]
166 pub fn wasm_app_push_paste(text: ::std::string::String) {
167 __RG_WASM_APP.with(|cell| {
168 if let ::std::option::Option::Some(s) = cell.borrow_mut().as_mut() {
169 ::retroglyph_core::backend::Input::push_event(
170 s.term.backend_mut(),
171 ::retroglyph_core::event::Event::Paste(text),
172 );
173 }
174 });
175 }
176
177 /// Queue a focus-change event: `true` for `Event::FocusGained`, `false` for
178 /// `Event::FocusLost`.
179 #[::wasm_bindgen::prelude::wasm_bindgen]
180 #[allow(missing_docs)]
181 pub fn wasm_app_push_focus(focused: bool) {
182 let event = if focused {
183 ::retroglyph_core::event::Event::FocusGained
184 } else {
185 ::retroglyph_core::event::Event::FocusLost
186 };
187 __RG_WASM_APP.with(|cell| {
188 if let ::std::option::Option::Some(s) = cell.borrow_mut().as_mut() {
189 ::retroglyph_core::backend::Input::push_event(s.term.backend_mut(), event);
190 }
191 });
192 }
193
194 /// Run one `App::update`, present unless it returned `Flow::Idle` (or already
195 /// presented itself), and return the ANSI bytes rendered since the last call. Returns
196 /// an empty string if called before `wasm_app_init`.
197 #[::wasm_bindgen::prelude::wasm_bindgen]
198 #[must_use]
199 #[allow(missing_docs)]
200 pub fn wasm_app_tick() -> ::std::string::String {
201 __RG_WASM_APP.with(|cell| {
202 let mut guard = cell.borrow_mut();
203 let Some(s) = guard.as_mut() else {
204 return ::std::string::String::new();
205 };
206 let now = ::web_time::Instant::now();
207 let delta = ::std::cmp::min(now.duration_since(s.last_tick), MAX_TICK_DELTA);
208 s.last_tick = now;
209 let frame = ::retroglyph_core::app::Frame {
210 delta,
211 frame: s.frame_count,
212 };
213 s.frame_count = s.frame_count.wrapping_add(1);
214 let present_count_before = s.term.present_count();
215 let flow = ::retroglyph_core::app::App::update(&mut s.app, &mut s.term, &frame);
216 if flow == ::retroglyph_core::app::Flow::Exit {
217 s.exited = true;
218 }
219 if flow != ::retroglyph_core::app::Flow::Idle
220 && s.term.present_count() == present_count_before
221 {
222 let _ = s.term.present();
223 }
224 s.term.backend_mut().take_output()
225 })
226 }
227
228 /// `true` once `$A::update` has returned `Flow::Exit` at least once. See this
229 /// macro's own doc comment for why the caller (not this crate) decides what "exit"
230 /// means for a browser tab.
231 #[::wasm_bindgen::prelude::wasm_bindgen]
232 #[must_use]
233 #[allow(missing_docs)]
234 pub fn wasm_app_exited() -> bool {
235 __RG_WASM_APP.with(|cell| {
236 cell.borrow()
237 .as_ref()
238 .is_some_and(|s| s.exited)
239 })
240 }
241
242 // Required symbol for the wasm32 binary target; JS never calls it directly in this
243 // entry mode (no event loop to kick off at module-load time; everything is pushed
244 // in from JS instead). Matches the equivalent comment on
245 // `examples::__wasm_terminal_entry!`.
246 fn main() {}
247 };
248 };
249}