Skip to main content

nightshade_api/web/
engine.rs

1//! A higher-level wrapper over `Viewport` that manages a render worker connection,
2//! queues messages until it is ready, and exposes reactive render state.
3
4use crate::wire::{FromWorker, SelectedEntity, ToWorker};
5use leptos::prelude::*;
6use serde::Serialize;
7use wasm_bindgen::{JsCast, JsValue};
8
9use crate::web::viewport::{Bridge, Viewport, ViewportEvent};
10
11/// Reactive signals reflecting the render worker's status, kept in sync from the
12/// messages it sends back.
13#[derive(Clone, Copy)]
14pub struct EngineState {
15    /// Whether the worker has reported that it is ready.
16    pub ready: RwSignal<bool>,
17    /// A human-readable name of the graphics adapter in use.
18    pub adapter: RwSignal<String>,
19    /// The most recent frames-per-second reading.
20    pub fps: RwSignal<f32>,
21    /// The current number of entities in the scene.
22    pub entity_count: RwSignal<u32>,
23    /// The currently selected entity, if any.
24    pub selected: RwSignal<Option<SelectedEntity>>,
25    /// Whether a pointer drag is in progress over the surface.
26    pub grabbing: RwSignal<bool>,
27}
28
29impl EngineState {
30    fn new() -> Self {
31        Self {
32            ready: RwSignal::new(false),
33            adapter: RwSignal::new(String::new()),
34            fps: RwSignal::new(0.0),
35            entity_count: RwSignal::new(0),
36            selected: RwSignal::new(None),
37            grabbing: RwSignal::new(false),
38        }
39    }
40}
41
42/// A `Copy` handle to the render worker connection. Holds the reactive `state`,
43/// buffers outgoing messages until the worker connects, and routes custom messages
44/// to a registered handler.
45#[derive(Clone, Copy)]
46pub struct Engine {
47    /// The reactive render state exposed to the UI.
48    pub state: EngineState,
49    bridge: StoredValue<Option<Bridge>, LocalStorage>,
50    custom_handler: StoredValue<Option<Callback<serde_json::Value>>, LocalStorage>,
51    queue: StoredValue<Vec<ToWorker>, LocalStorage>,
52    worker_url: StoredValue<String>,
53}
54
55impl Engine {
56    /// Sends an application-defined message to the worker as a custom payload,
57    /// queuing it if the worker is not yet connected.
58    pub fn send<Message: Serialize>(&self, message: &Message) {
59        if let Ok(value) = serde_json::to_value(message) {
60            self.dispatch(ToWorker::Custom(value));
61        }
62    }
63
64    /// Registers the handler invoked for each custom message received from the worker.
65    pub fn on_custom(&self, handler: Callback<serde_json::Value>) {
66        self.custom_handler.set_value(Some(handler));
67    }
68
69    fn dispatch(&self, message: ToWorker) {
70        if let Some(bridge) = self.bridge.get_value() {
71            bridge.send(&message);
72        } else {
73            self.queue.update_value(|queue| queue.push(message));
74        }
75    }
76
77    fn connect(&self, bridge: Bridge, canvas: &web_sys::OffscreenCanvas, width: f32, height: f32) {
78        bridge.send_with_canvas(&ToWorker::Init { width, height }, canvas);
79        for message in self.queue.get_value() {
80            bridge.send(&message);
81        }
82        self.queue.set_value(Vec::new());
83        self.bridge.set_value(Some(bridge));
84    }
85}
86
87/// Creates an `Engine` for the worker at `worker_url` and installs global key
88/// listeners that forward keystrokes to the worker, skipping modifier combos and
89/// keys pressed while typing in a form field. Pair it with `EngineViewport`.
90pub fn use_engine(worker_url: impl Into<String>) -> Engine {
91    let engine = Engine {
92        state: EngineState::new(),
93        bridge: StoredValue::new_local(None),
94        custom_handler: StoredValue::new_local(None),
95        queue: StoredValue::new_local(Vec::new()),
96        worker_url: StoredValue::new(worker_url.into()),
97    };
98
99    let _ = window_event_listener(leptos::ev::keydown, move |event| {
100        if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
101            return;
102        }
103        let text = (event.key().chars().count() == 1).then(|| event.key());
104        engine.dispatch(ToWorker::Key {
105            code: event.code(),
106            pressed: true,
107            text,
108        });
109    });
110    let _ = window_event_listener(leptos::ev::keyup, move |event| {
111        if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
112            return;
113        }
114        engine.dispatch(ToWorker::Key {
115            code: event.code(),
116            pressed: false,
117            text: None,
118        });
119    });
120
121    engine
122}
123
124/// Renders the `Viewport` wired to `engine`: it translates `ViewportEvent`s into
125/// worker messages, completes the connection on connect, and updates the engine
126/// `state` (and any custom handler) from messages the worker sends back.
127#[component]
128pub fn EngineViewport(engine: Engine) -> impl IntoView {
129    let state = engine.state;
130    let custom_handler = engine.custom_handler;
131
132    let on_input = Callback::new(move |event: ViewportEvent| {
133        let message = match event {
134            ViewportEvent::Resize { width, height } => ToWorker::Resize { width, height },
135            ViewportEvent::PointerMove { x, y } => ToWorker::PointerMove { x, y },
136            ViewportEvent::PointerButton { button, pressed } => {
137                ToWorker::PointerButton { button, pressed }
138            }
139            ViewportEvent::Wheel { delta } => ToWorker::Wheel { delta },
140            ViewportEvent::Touch { id, phase, x, y } => ToWorker::Touch { id, phase, x, y },
141            ViewportEvent::Pick { x, y } => ToWorker::Pick { x, y },
142        };
143        engine.dispatch(message);
144    });
145
146    let on_connect = Callback::new(
147        move |(connected, canvas, width, height): (Bridge, web_sys::OffscreenCanvas, f32, f32)| {
148            engine.connect(connected, &canvas, width, height);
149        },
150    );
151
152    let on_message = Callback::new(move |payload: JsValue| {
153        let Ok(message) = serde_wasm_bindgen::from_value::<FromWorker>(payload) else {
154            return;
155        };
156        match message {
157            FromWorker::Ready { adapter } => {
158                state.adapter.set(adapter);
159                state.ready.set(true);
160            }
161            FromWorker::Stats { fps, entity_count } => {
162                state.fps.set(fps);
163                state.entity_count.set(entity_count);
164            }
165            FromWorker::Selected { detail } => state.selected.set(detail),
166            FromWorker::Custom(value) => {
167                if let Some(handler) = custom_handler.get_value() {
168                    handler.run(value);
169                }
170            }
171        }
172    });
173
174    view! {
175        <Viewport
176            worker_url=engine.worker_url.get_value()
177            grabbing=state.grabbing
178            on_input=on_input
179            on_connect=on_connect
180            on_message=on_message
181        />
182    }
183}
184
185fn typing_in_field(event: &web_sys::KeyboardEvent) -> bool {
186    event
187        .target()
188        .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
189        .map(|element| {
190            let tag = element.tag_name();
191            tag.eq_ignore_ascii_case("input")
192                || tag.eq_ignore_ascii_case("textarea")
193                || tag.eq_ignore_ascii_case("select")
194                || element.is_content_editable()
195        })
196        .unwrap_or(false)
197}