Skip to main content

nightshade_api/web/
engine.rs

1//! A higher-level wrapper over `Viewport` that manages a render worker
2//! connection, queues messages until it is ready, and exposes reactive
3//! render state. [`Engine`] is generic over the [`WorkerProtocol`] the app
4//! speaks; the default [`ValueProtocol`] sends and receives JSON values so
5//! a small app never declares message enums.
6
7use crate::transfer::{app_value_from, attachments_from, control_value_from};
8use crate::wire::{
9    Attachments, FromWorker, SelectedEntity, ToWorker, ValueProtocol, WorkerProtocol,
10};
11use leptos::prelude::*;
12use serde::Serialize;
13use wasm_bindgen::{JsCast, JsValue};
14
15use crate::web::viewport::{Bridge, Viewport, ViewportEvent};
16
17/// Reactive signals reflecting the render worker's status, kept in sync from the
18/// messages it sends back.
19#[derive(Clone, Copy)]
20pub struct EngineState {
21    /// Whether the worker has reported that it is ready.
22    pub ready: RwSignal<bool>,
23    /// A human-readable name of the graphics adapter in use.
24    pub adapter: RwSignal<String>,
25    /// The most recent frames-per-second reading.
26    pub fps: RwSignal<f32>,
27    /// The current number of entities in the scene.
28    pub entity_count: RwSignal<u32>,
29    /// The currently selected entity, if any.
30    pub selected: RwSignal<Option<SelectedEntity>>,
31    /// Whether a pointer drag is in progress over the surface.
32    pub grabbing: RwSignal<bool>,
33    /// The render surface size in physical pixels, kept current from the
34    /// connect dimensions and every resize.
35    pub surface_size: RwSignal<(f32, f32)>,
36}
37
38impl EngineState {
39    fn new() -> Self {
40        Self {
41            ready: RwSignal::new(false),
42            adapter: RwSignal::new(String::new()),
43            fps: RwSignal::new(0.0),
44            entity_count: RwSignal::new(0),
45            selected: RwSignal::new(None),
46            grabbing: RwSignal::new(false),
47            surface_size: RwSignal::new((0.0, 0.0)),
48        }
49    }
50}
51
52enum Queued<P: WorkerProtocol> {
53    Control(ToWorker),
54    App(P::Incoming, Attachments),
55}
56
57type AppHandler<P> = std::rc::Rc<dyn Fn(<P as WorkerProtocol>::Outgoing, Attachments)>;
58
59/// A `Copy` handle to the render worker connection. Holds the reactive
60/// `state`, buffers outgoing messages until the worker connects, and routes
61/// application messages to a registered handler.
62pub struct Engine<P: WorkerProtocol + 'static = ValueProtocol> {
63    /// The reactive render state exposed to the UI.
64    pub state: EngineState,
65    bridge: StoredValue<Option<Bridge>, LocalStorage>,
66    app_handler: StoredValue<Option<AppHandler<P>>, LocalStorage>,
67    queue: StoredValue<Vec<Queued<P>>, LocalStorage>,
68    worker_url: StoredValue<String>,
69}
70
71impl<P: WorkerProtocol + 'static> Clone for Engine<P> {
72    fn clone(&self) -> Self {
73        *self
74    }
75}
76
77impl<P: WorkerProtocol + 'static> Copy for Engine<P> {}
78
79impl<P: WorkerProtocol + 'static> Engine<P> {
80    /// Sends an application message to the worker, queuing it if the worker
81    /// is not yet connected.
82    pub fn send_app(&self, message: P::Incoming) {
83        self.send_app_with_attachments(message, Vec::new());
84    }
85
86    /// Sends an application message with named binary attachments, whose
87    /// buffers ride the transfer list.
88    pub fn send_app_with_attachments(&self, message: P::Incoming, attachments: Attachments) {
89        if let Some(bridge) = self.bridge.get_value() {
90            bridge.send_app(&message, &attachments);
91        } else {
92            self.queue
93                .update_value(|queue| queue.push(Queued::App(message, attachments)));
94        }
95    }
96
97    /// Registers the handler invoked for each application message received
98    /// from the worker, along with any attachments it carried.
99    pub fn on_app_message(&self, handler: impl Fn(P::Outgoing, Attachments) + 'static) {
100        self.app_handler.set_value(Some(std::rc::Rc::new(handler)));
101    }
102
103    /// Forwards one keyboard event to the worker, for pages that own their
104    /// key handling instead of installing [`Engine::install_key_forwarding`].
105    pub fn send_key(&self, code: String, pressed: bool, text: Option<String>) {
106        self.dispatch(ToWorker::Key {
107            code,
108            pressed,
109            text,
110        });
111    }
112
113    /// Installs global keydown and keyup listeners that forward keystrokes
114    /// to the worker, skipping modifier combos and keys pressed while typing
115    /// in a form field.
116    pub fn install_key_forwarding(&self) {
117        let engine = *self;
118        let _ = window_event_listener(leptos::ev::keydown, move |event| {
119            if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
120                return;
121            }
122            let text = (event.key().chars().count() == 1).then(|| event.key());
123            engine.send_key(event.code(), true, text);
124        });
125        let _ = window_event_listener(leptos::ev::keyup, move |event| {
126            if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
127                return;
128            }
129            engine.send_key(event.code(), false, None);
130        });
131    }
132
133    fn dispatch(&self, message: ToWorker) {
134        if let Some(bridge) = self.bridge.get_value() {
135            bridge.send(&message);
136        } else {
137            self.queue
138                .update_value(|queue| queue.push(Queued::Control(message)));
139        }
140    }
141
142    fn connect(&self, bridge: Bridge, canvas: &web_sys::OffscreenCanvas, width: f32, height: f32) {
143        self.state.surface_size.set((width, height));
144        bridge.send_with_canvas(&ToWorker::Init { width, height }, canvas);
145        let backlog = self
146            .queue
147            .try_update_value(std::mem::take)
148            .unwrap_or_default();
149        for queued in backlog {
150            match queued {
151                Queued::Control(message) => bridge.send(&message),
152                Queued::App(message, attachments) => bridge.send_app(&message, &attachments),
153            }
154        }
155        self.bridge.set_value(Some(bridge));
156    }
157}
158
159impl Engine<ValueProtocol> {
160    /// Sends an application-defined message to the worker as a JSON value,
161    /// queuing it if the worker is not yet connected.
162    pub fn send<Message: Serialize>(&self, message: &Message) {
163        if let Ok(value) = serde_json::to_value(message) {
164            self.send_app(value);
165        }
166    }
167
168    /// Registers the handler invoked for each JSON message received from
169    /// the worker.
170    pub fn on_custom(&self, handler: Callback<serde_json::Value>) {
171        self.on_app_message(move |value, _attachments| {
172            handler.run(value);
173        });
174    }
175}
176
177/// Creates an [`Engine`] over [`ValueProtocol`] for the worker at
178/// `worker_url`, with the default key forwarding installed. The form for
179/// apps without message enums; pair it with [`EngineViewport`].
180pub fn use_engine(worker_url: impl Into<String>) -> Engine<ValueProtocol> {
181    let engine = use_engine_typed(worker_url);
182    engine.install_key_forwarding();
183    engine
184}
185
186/// Creates an [`Engine`] over the app's own [`WorkerProtocol`] for the
187/// worker at `worker_url`. Pair it with [`EngineViewport`], and either
188/// install the default key forwarding with
189/// [`Engine::install_key_forwarding`] or forward keys through
190/// [`Engine::send_key`].
191pub fn use_engine_typed<P: WorkerProtocol + 'static>(worker_url: impl Into<String>) -> Engine<P> {
192    Engine {
193        state: EngineState::new(),
194        bridge: StoredValue::new_local(None),
195        app_handler: StoredValue::new_local(None),
196        queue: StoredValue::new_local(Vec::new()),
197        worker_url: StoredValue::new(worker_url.into()),
198    }
199}
200
201/// Renders the `Viewport` wired to `engine`: it translates `ViewportEvent`s into
202/// worker messages, completes the connection on connect, and updates the engine
203/// `state` (and any application message handler) from messages the worker sends back.
204#[component]
205pub fn EngineViewport<P: WorkerProtocol + 'static>(engine: Engine<P>) -> impl IntoView {
206    let state = engine.state;
207    let app_handler = engine.app_handler;
208
209    let on_input = Callback::new(move |event: ViewportEvent| {
210        let message = match event {
211            ViewportEvent::Resize { width, height } => {
212                state.surface_size.set((width, height));
213                ToWorker::Resize { width, height }
214            }
215            ViewportEvent::PointerMove { x, y } => ToWorker::PointerMove { x, y },
216            ViewportEvent::PointerMotion { dx, dy } => ToWorker::PointerMotion { dx, dy },
217            ViewportEvent::PointerButton { button, pressed } => {
218                ToWorker::PointerButton { button, pressed }
219            }
220            ViewportEvent::Wheel { delta } => ToWorker::Wheel { delta },
221            ViewportEvent::Touch { id, phase, x, y } => ToWorker::Touch { id, phase, x, y },
222            ViewportEvent::Pick { x, y } => ToWorker::Pick { x, y },
223        };
224        engine.dispatch(message);
225    });
226
227    let on_connect = Callback::new(
228        move |(connected, canvas, width, height): (Bridge, web_sys::OffscreenCanvas, f32, f32)| {
229            engine.connect(connected, &canvas, width, height);
230        },
231    );
232
233    let on_message = Callback::new(move |data: JsValue| {
234        if let Some(payload) = control_value_from(&data) {
235            let Ok(message) = serde_wasm_bindgen::from_value::<FromWorker>(payload) else {
236                return;
237            };
238            match message {
239                FromWorker::Ready { adapter } => {
240                    state.adapter.set(adapter);
241                    state.ready.set(true);
242                }
243                FromWorker::Stats { fps, entity_count } => {
244                    state.fps.set(fps);
245                    state.entity_count.set(entity_count);
246                }
247                FromWorker::Selected { detail } => state.selected.set(detail),
248            }
249            return;
250        }
251        if let Some(payload) = app_value_from(&data)
252            && let Ok(message) = serde_wasm_bindgen::from_value::<P::Outgoing>(payload)
253            && let Some(handler) = app_handler.get_value()
254        {
255            handler(message, attachments_from(&data));
256        }
257    });
258
259    view! {
260        <Viewport
261            worker_url=engine.worker_url.get_value()
262            grabbing=state.grabbing
263            on_input=on_input
264            on_connect=on_connect
265            on_message=on_message
266        />
267    }
268}
269
270fn typing_in_field(event: &web_sys::KeyboardEvent) -> bool {
271    event
272        .target()
273        .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
274        .map(|element| {
275            let tag = element.tag_name();
276            tag.eq_ignore_ascii_case("input")
277                || tag.eq_ignore_ascii_case("textarea")
278                || tag.eq_ignore_ascii_case("select")
279                || element.is_content_editable()
280        })
281        .unwrap_or(false)
282}