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            /// Runs `f` against the live `__RgWasmAppState`, or does nothing and returns `None` if
97            /// called before `wasm_app_init`.
98            fn with_state<R>(
99                f: impl ::std::ops::FnOnce(&mut __RgWasmAppState) -> R,
100            ) -> ::std::option::Option<R> {
101                __RG_WASM_APP.with(|cell| cell.borrow_mut().as_mut().map(f))
102            }
103
104            /// Queues `event` on the live state's backend. A no-op before `wasm_app_init`.
105            fn push(event: ::retroglyph_core::event::Event) {
106                with_state(|s| {
107                    ::retroglyph_core::backend::Input::push_event(s.term.backend_mut(), event);
108                });
109            }
110
111            /// Build the `Terminal<TerminalWasm>` and `$A::default()`. Call before the first
112            /// `wasm_app_tick`.
113            #[::wasm_bindgen::prelude::wasm_bindgen]
114            #[allow(missing_docs)]
115            pub fn wasm_app_init(width: u16, height: u16) {
116                ::console_error_panic_hook::set_once();
117                let mut backend = $crate::TerminalWasm::new(width, height);
118                ::retroglyph_core::backend::Cursor::set_cursor_visible(&mut backend, false);
119                let term = ::retroglyph_core::terminal::Terminal::new(backend);
120                __RG_WASM_APP.with(|cell| {
121                    *cell.borrow_mut() = ::std::option::Option::Some(__RgWasmAppState {
122                        term,
123                        app: <$A as ::std::default::Default>::default(),
124                        last_tick: ::web_time::Instant::now(),
125                        frame_count: 0,
126                        exited: false,
127                    });
128                });
129            }
130
131            /// Report a new size (in cells), e.g. after the host terminal emulator re-fits on a
132            /// window resize. No-op if called before `wasm_app_init`.
133            #[::wasm_bindgen::prelude::wasm_bindgen]
134            #[allow(missing_docs)]
135            pub fn wasm_app_resize(width: u16, height: u16) {
136                with_state(|s| $crate::resize_terminal(&mut s.term, width, height));
137            }
138
139            /// Decode and queue a key event via [`decode_key_event`](crate::decode_key_event).
140            #[::wasm_bindgen::prelude::wasm_bindgen]
141            #[allow(missing_docs)]
142            pub fn wasm_app_push_key(code: u32, mods: u8) {
143                let Some(event) = $crate::decode_key_event(code, mods) else {
144                    return;
145                };
146                push(::retroglyph_core::event::Event::Key(event));
147            }
148
149            /// Decode and queue a pointer (mouse/touch) event via
150            /// [`decode_mouse_event`](crate::decode_mouse_event).
151            #[::wasm_bindgen::prelude::wasm_bindgen]
152            #[allow(missing_docs)]
153            pub fn wasm_app_push_mouse(x: u16, y: u16, action: u8, button: u8, mods: u8) {
154                let Some(event) = $crate::decode_mouse_event(x, y, action, button, mods) else {
155                    return;
156                };
157                push(::retroglyph_core::event::Event::Mouse(event));
158            }
159
160            /// Queue pasted text as a single `Event::Paste`, not one `Event::Key` per character.
161            #[::wasm_bindgen::prelude::wasm_bindgen]
162            #[allow(missing_docs)]
163            pub fn wasm_app_push_paste(text: ::std::string::String) {
164                push(::retroglyph_core::event::Event::Paste(text));
165            }
166
167            /// Queue a focus-change event: `true` for `Event::FocusGained`, `false` for
168            /// `Event::FocusLost`.
169            #[::wasm_bindgen::prelude::wasm_bindgen]
170            #[allow(missing_docs)]
171            pub fn wasm_app_push_focus(focused: bool) {
172                let event = if focused {
173                    ::retroglyph_core::event::Event::FocusGained
174                } else {
175                    ::retroglyph_core::event::Event::FocusLost
176                };
177                push(event);
178            }
179
180            /// Run one `App::update`, present unless it returned `Flow::Idle` (or already
181            /// presented itself), and return the ANSI bytes rendered since the last call. Returns
182            /// an empty string if called before `wasm_app_init`.
183            #[::wasm_bindgen::prelude::wasm_bindgen]
184            #[must_use]
185            #[allow(missing_docs)]
186            pub fn wasm_app_tick() -> ::std::string::String {
187                __RG_WASM_APP.with(|cell| {
188                    let mut guard = cell.borrow_mut();
189                    let Some(s) = guard.as_mut() else {
190                        return ::std::string::String::new();
191                    };
192                    let now = ::web_time::Instant::now();
193                    let delta = ::std::cmp::min(now.duration_since(s.last_tick), MAX_TICK_DELTA);
194                    s.last_tick = now;
195                    let frame = ::retroglyph_core::app::Frame::new(delta, s.frame_count);
196                    s.frame_count = s.frame_count.wrapping_add(1);
197                    let present_count_before = s.term.present_count();
198                    let flow = ::retroglyph_core::app::App::update(&mut s.app, &mut s.term, &frame);
199                    if flow == ::retroglyph_core::app::Flow::Exit {
200                        s.exited = true;
201                    }
202                    if flow != ::retroglyph_core::app::Flow::Idle
203                        && s.term.present_count() == present_count_before
204                    {
205                        let _ = s.term.present();
206                    }
207                    s.term.backend_mut().take_output()
208                })
209            }
210
211            /// `true` once `$A::update` has returned `Flow::Exit` at least once. See this
212            /// macro's own doc comment for why the caller (not this crate) decides what "exit"
213            /// means for a browser tab.
214            #[::wasm_bindgen::prelude::wasm_bindgen]
215            #[must_use]
216            #[allow(missing_docs)]
217            pub fn wasm_app_exited() -> bool {
218                __RG_WASM_APP.with(|cell| {
219                    cell.borrow()
220                        .as_ref()
221                        .is_some_and(|s| s.exited)
222                })
223            }
224
225            // Required symbol for the wasm32 binary target; JS never calls it directly in this
226            // entry mode (no event loop to kick off at module-load time; everything is pushed
227            // in from JS instead). Matches the equivalent comment on
228            // `examples::__wasm_terminal_entry!`.
229            fn main() {}
230        };
231    };
232}