Skip to main content

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}