Skip to main content

fc_dioxus/core/
platform.rs

1//! Host access stays here so components keep renderer-independent signatures.
2//!
3//! Eval-capable hosts use a fixed script and a data channel. SSR and native hosts
4//! report `Unsupported`, allowing callers to keep their native markup fallback.
5
6use super::{attrs::ThemePairs, id::Id};
7use dioxus::prelude::*;
8use std::fmt;
9
10/// Capabilities detected after mount, never used to change the initial tree.
11#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
12pub struct Capabilities {
13    /// The host implements modal dialogs.
14    pub modal: bool,
15    /// The host implements the popover API.
16    pub popover: bool,
17    /// The host implements clipboard writes.
18    pub clipboard: bool,
19}
20
21/// A user preference the host reports through a media query. Fixed queries only, so no
22/// caller can pass the bridge arbitrary text.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum MediaPreference {
26    /// `prefers-color-scheme: dark`.
27    DarkScheme,
28    /// `prefers-contrast: more`.
29    MoreContrast,
30    /// `forced-colors: active`.
31    ForcedColors,
32}
33impl MediaPreference {
34    const fn query(self) -> &'static str {
35        match self {
36            Self::DarkScheme => "(prefers-color-scheme: dark)",
37            Self::MoreContrast => "(prefers-contrast: more)",
38            Self::ForcedColors => "(forced-colors: active)",
39        }
40    }
41}
42
43/// A user preference as it changes, from [`DomBridge::watch`].
44pub struct PreferenceWatch {
45    eval: document::Eval,
46    started: bool,
47}
48impl PreferenceWatch {
49    /// The next value, or `None` when the host cannot report it.
50    pub async fn next(&mut self) -> Option<bool> {
51        if !self.started {
52            return None;
53        }
54        self.eval.recv::<bool>().await.ok()
55    }
56}
57
58/// A host operation could not be performed.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum DomError {
62    /// SSR, native, or a host without the requested capability.
63    Unsupported,
64    /// The target ID does not exist.
65    MissingTarget,
66    /// The host rejected the request (for example clipboard permissions).
67    Rejected,
68}
69impl fmt::Display for DomError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "host operation: {self:?}")
72    }
73}
74impl std::error::Error for DomError {}
75
76/// The position to which a node is scrolled.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
78pub enum ScrollBlock {
79    /// Nearest visible edge, avoiding unnecessary motion.
80    #[default]
81    Nearest,
82    /// Block-start edge.
83    Start,
84    /// Center of the visible area.
85    Center,
86}
87
88/// Which side of a popup sits at a point's x.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum Edge {
91    /// The popup's left edge.
92    Left,
93    /// The popup's right edge.
94    Right,
95}
96
97/// A viewport point a popup opens at, in CSS pixels.
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct MenuPoint {
100    /// Horizontal position.
101    pub x: f64,
102    /// Vertical position.
103    pub y: f64,
104    /// Where the popup's bottom goes if it would cross the viewport's, e.g. a
105    /// trigger's top.
106    pub flip_y: Option<f64>,
107    /// Which edge sits at `x`; the inline start when `None`.
108    pub edge: Option<Edge>,
109}
110
111/// An asynchronous host bridge; errors are acknowledged by the host.
112#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
113pub struct DomBridge;
114
115impl DomBridge {
116    async fn send(operation: &str, data: &str, option: &str) -> Result<(), DomError> {
117        let eval = document::eval(include_str!("platform.js"));
118        eval.send((operation, data, option))
119            .map_err(|_| DomError::Unsupported)?;
120        match eval
121            .join::<String>()
122            .await
123            .map_err(|_| DomError::Unsupported)?
124            .as_str()
125        {
126            "ok" => Ok(()),
127            "missing" => Err(DomError::MissingTarget),
128            "unsupported" => Err(DomError::Unsupported),
129            _ => Err(DomError::Rejected),
130        }
131    }
132    /// Open a dialog modally after mount. Fall back to an inline open dialog.
133    /// # Errors
134    /// Returns a host error when the target or API is unavailable.
135    pub async fn show_modal(self, id: &Id) -> Result<(), DomError> {
136        Self::send("modal", id.as_str(), "").await
137    }
138    /// Close a dialog. Fall back to removing its open state in the virtual DOM.
139    /// # Errors
140    /// Returns a host error when the target or API is unavailable.
141    pub async fn close_dialog(self, id: &Id) -> Result<(), DomError> {
142        Self::send("close", id.as_str(), "").await
143    }
144    /// Change popover visibility. Fall back to an in-flow panel.
145    /// # Errors
146    /// Returns a host error when the target or API is unavailable.
147    pub async fn toggle_popover(self, id: &Id, open: bool) -> Result<(), DomError> {
148        Self::send("popover", id.as_str(), if open { "open" } else { "close" }).await
149    }
150    /// Focus a node. Prefer `MountedData::set_focus` when a mount handle exists.
151    /// # Errors
152    /// Returns a host error when the target or API is unavailable.
153    pub async fn focus(self, id: &Id) -> Result<(), DomError> {
154        Self::send("focus", id.as_str(), "").await
155    }
156    /// Scroll a node into view; unsupported hosts leave the viewport unchanged.
157    /// # Errors
158    /// Returns a host error when the target or API is unavailable.
159    pub async fn scroll_into_view(self, id: &Id, block: ScrollBlock) -> Result<(), DomError> {
160        Self::send(
161            "scroll",
162            id.as_str(),
163            match block {
164                ScrollBlock::Nearest => "nearest",
165                ScrollBlock::Start => "start",
166                ScrollBlock::Center => "center",
167            },
168        )
169        .await
170    }
171    /// Whether a scroll container is at its inline start and end, read from the host the
172    /// way upstream's `carousel.ts` does, RTL included.
173    /// # Errors
174    /// Returns a host error when the target or API is unavailable.
175    pub async fn scroll_edges(self, id: &Id) -> Result<(bool, bool), DomError> {
176        let eval = document::eval(include_str!("platform.js"));
177        eval.send(("edges", id.as_str(), ""))
178            .map_err(|_| DomError::Unsupported)?;
179        let answer = eval
180            .join::<String>()
181            .await
182            .map_err(|_| DomError::Unsupported)?;
183        match answer.split_once(' ') {
184            Some((start, end)) => Ok((start == "true", end == "true")),
185            None if answer == "missing" => Err(DomError::MissingTarget),
186            None => Err(DomError::Unsupported),
187        }
188    }
189    /// Scroll a container by most of its visible width, forwards in the reading direction
190    /// or back.
191    /// # Errors
192    /// Returns a host error when the target or API is unavailable.
193    pub async fn page(self, id: &Id, forward: bool) -> Result<(), DomError> {
194        Self::send("page", id.as_str(), if forward { "next" } else { "prev" }).await
195    }
196    /// Place a popup list under its invoker, or above it when there is no room below,
197    /// capped to the space available. Positioning needs layout, so it is the host's.
198    /// # Errors
199    /// Returns a host error when the target or API is unavailable.
200    pub async fn place_below(self, list: &Id, invoker: &Id) -> Result<(), DomError> {
201        Self::send("place", list.as_str(), invoker.as_str()).await
202    }
203    /// Place a fixed popup at a viewport point, as upstream's `menu.ts` does: its start
204    /// edge at `x` (or `edge`), clamped inside the viewport, and moved above `flip_y`
205    /// when it would cross the bottom.
206    /// # Errors
207    /// Returns a host error when the target or API is unavailable.
208    pub async fn place_at(self, id: &Id, point: MenuPoint) -> Result<(), DomError> {
209        let edge = match point.edge {
210            Some(Edge::Left) => "left",
211            Some(Edge::Right) => "right",
212            None => "",
213        };
214        let flip = point.flip_y.map(|f| f.to_string()).unwrap_or_default();
215        Self::send(
216            "point",
217            id.as_str(),
218            &format!("{},{},{flip},{edge}", point.x, point.y),
219        )
220        .await
221    }
222    /// The point a menu opens at under a trigger: its start edge, 4px below, flipping to
223    /// 4px above.
224    /// # Errors
225    /// Returns a host error when the target or API is unavailable.
226    pub async fn point_below(self, trigger: &Id) -> Result<MenuPoint, DomError> {
227        let eval = document::eval(include_str!("platform.js"));
228        eval.send(("below", trigger.as_str(), ""))
229            .map_err(|_| DomError::Unsupported)?;
230        let answer = eval
231            .join::<String>()
232            .await
233            .map_err(|_| DomError::Unsupported)?;
234        let parts: Vec<&str> = answer.split(',').collect();
235        match parts.as_slice() {
236            [x, y, flip, edge] => Ok(MenuPoint {
237                x: x.parse().map_err(|_| DomError::Rejected)?,
238                y: y.parse().map_err(|_| DomError::Rejected)?,
239                flip_y: flip.parse().ok(),
240                edge: Some(if *edge == "right" {
241                    Edge::Right
242                } else {
243                    Edge::Left
244                }),
245            }),
246            _ if answer == "missing" => Err(DomError::MissingTarget),
247            _ => Err(DomError::Unsupported),
248        }
249    }
250    /// Route a pointer's events to an element for the rest of a drag.
251    /// # Errors
252    /// Returns a host error when the target or API is unavailable.
253    pub async fn capture_pointer(self, id: &Id, pointer: i32) -> Result<(), DomError> {
254        Self::send("capture", id.as_str(), &pointer.to_string()).await
255    }
256    /// Show a checkbox as mixed, which only a DOM property can express. Without a host
257    /// the checkbox renders unchecked.
258    /// # Errors
259    /// Returns a host error when the target or API is unavailable.
260    pub async fn set_indeterminate(self, id: &Id, mixed: bool) -> Result<(), DomError> {
261        Self::send(
262            "indeterminate",
263            id.as_str(),
264            if mixed { "on" } else { "off" },
265        )
266        .await
267    }
268    /// Copy text through a data channel. On failure, retain selectable source text.
269    /// # Errors
270    /// Returns `Unsupported` or `Rejected` when clipboard access is unavailable.
271    pub async fn copy_text(self, text: &str) -> Result<(), DomError> {
272        Self::send("copy", text, "").await
273    }
274    /// Wait `ms` milliseconds on the host's clock, for timed behaviour such as toast
275    /// expiry. Hosts without a script runtime return `Unsupported` at once, and callers
276    /// keep their untimed fallback (a toast then stays until dismissed).
277    /// # Errors
278    /// Returns `Unsupported` when the host cannot run timers.
279    pub async fn delay(self, ms: u32) -> Result<(), DomError> {
280        let eval = document::eval(
281            "const ms = await dioxus.recv(); await new Promise(r => setTimeout(r, ms)); return 'ok';",
282        );
283        eval.send(ms).map_err(|_| DomError::Unsupported)?;
284        match eval.join::<String>().await {
285            Ok(_) => Ok(()),
286            Err(_) => Err(DomError::Unsupported),
287        }
288    }
289    /// Reload a frame in place. Re-rendering the same `src` would not navigate.
290    /// # Errors
291    /// Returns a host error when the target is missing or not a frame.
292    pub async fn reload_frame(self, id: &Id) -> Result<(), DomError> {
293        Self::send("reload", id.as_str(), "").await
294    }
295    /// Whether a user preference is on, or `None` on hosts without media queries, where
296    /// callers keep their light, standard-contrast default.
297    pub async fn prefers(self, preference: MediaPreference) -> Option<bool> {
298        let eval = document::eval(
299            "const query = await dioxus.recv(); return typeof matchMedia === 'function' ? matchMedia(query).matches : null;",
300        );
301        eval.send(preference.query()).ok()?;
302        eval.join::<Option<bool>>().await.ok().flatten()
303    }
304    /// Follow a user preference: the current value, then each change. Hosts without
305    /// media queries end the stream at once.
306    #[must_use]
307    pub fn watch(self, preference: MediaPreference) -> PreferenceWatch {
308        let eval = document::eval(
309            "const query = await dioxus.recv(); if (typeof matchMedia !== 'function') return; const media = matchMedia(query); dioxus.send(media.matches); media.addEventListener('change', event => dioxus.send(event.matches)); await new Promise(() => {});",
310        );
311        let started = eval.send(preference.query()).is_ok();
312        PreferenceWatch { eval, started }
313    }
314    /// Mirror a root's theme attributes onto `<html>`, or release them with `None`.
315    /// `token` names the root and `generation` orders its updates, so the document side
316    /// can drop one that arrives late and restore what the page had once no root owns a
317    /// name. Hosts without a document keep the theme scoped to the root's own element.
318    /// # Errors
319    /// Returns `Unsupported` when the host cannot run script.
320    pub(crate) async fn mirror_document_theme(
321        self,
322        token: u64,
323        generation: u64,
324        pairs: Option<&ThemePairs>,
325    ) -> Result<(), DomError> {
326        let eval = Self::send_document_theme(token, generation, pairs)?;
327        match eval.join::<String>().await {
328            Ok(_) => Ok(()),
329            Err(_) => Err(DomError::Unsupported),
330        }
331    }
332    /// Release a root's attributes on `<html>` without waiting: this runs as the root
333    /// unmounts, when nothing is left to await it.
334    // A method like the rest of the bridge, so every host call reads `DomBridge.…`.
335    #[allow(clippy::unused_self)]
336    pub(crate) fn release_document_theme(self, token: u64, generation: u64) {
337        let _ = Self::send_document_theme(token, generation, None);
338    }
339    fn send_document_theme(
340        token: u64,
341        generation: u64,
342        pairs: Option<&ThemePairs>,
343    ) -> Result<document::Eval, DomError> {
344        // Names and values arrive through the channel, never as script text.
345        let eval = document::eval(include_str!("document-theme.js"));
346        eval.send((token, generation, pairs.map(|pairs| pairs.to_vec())))
347            .map_err(|_| DomError::Unsupported)?;
348        Ok(eval)
349    }
350    /// Detect available host APIs after mounting; SSR and native return defaults.
351    pub async fn capabilities(self) -> Capabilities {
352        let eval = document::eval("return [typeof HTMLDialogElement !== 'undefined' && 'showModal' in HTMLDialogElement.prototype, 'showPopover' in HTMLElement.prototype, !!navigator.clipboard?.writeText];");
353        let (modal, popover, clipboard) =
354            eval.join::<(bool, bool, bool)>().await.unwrap_or_default();
355        Capabilities {
356            modal,
357            popover,
358            clipboard,
359        }
360    }
361}
362
363/// Read the web entrypoint's query string. Non-web hosts supply initial props.
364/// This is input to the application, not capability-dependent component markup.
365#[must_use]
366pub fn location_query() -> Option<String> {
367    #[cfg(all(feature = "web", target_arch = "wasm32"))]
368    {
369        web_sys::window().and_then(|window| window.location().search().ok())
370    }
371    #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
372    {
373        None
374    }
375}
376
377/// Something upstream's editor binding reported.
378#[derive(Debug, Clone, PartialEq, Eq)]
379#[non_exhaustive]
380pub enum EditorEvent {
381    /// The document changed; its HTML.
382    Change(String),
383    /// The marks covering the whole selection.
384    Marks(Vec<crate::models::rich_text::Mark>),
385}
386
387/// Upstream's editor binding attached to one `.fc-editor`. Events arrive in order.
388/// Dropping the session, as unmounting the editor does when its task ends, calls the
389/// binding's `destroy()`, so no listener outlives the component.
390pub struct EditorSession(document::Eval);
391impl EditorSession {
392    /// The next event, or `None` once the host stops sending.
393    pub async fn next(&mut self) -> Option<EditorEvent> {
394        let (kind, html, marks) = self
395            .0
396            .recv::<(String, String, Vec<crate::models::rich_text::Mark>)>()
397            .await
398            .ok()?;
399        match kind.as_str() {
400            "change" => Some(EditorEvent::Change(html)),
401            _ => Some(EditorEvent::Marks(marks)),
402        }
403    }
404    /// Replace the document. The HTML must come from the application's own state,
405    /// never from untrusted input: like upstream's `value` setter, this is `innerHTML`.
406    pub fn set_value(&self, html: &str) {
407        let _ = self.0.send(("set", html));
408    }
409}
410impl Drop for EditorSession {
411    fn drop(&mut self) {
412        let _ = self.0.send(("destroy", ""));
413    }
414}
415
416impl DomBridge {
417    /// Attach upstream's editor binding (`editor.ts`, shipped unchanged as JavaScript)
418    /// to the `.fc-editor` with this id.
419    /// # Errors
420    /// `Unsupported` where the host cannot load modules (server rendering, native), and
421    /// `MissingTarget` when no element has the id. The editor then stays plain,
422    /// editable text with an inert toolbar, which upstream calls degraded but not broken.
423    pub async fn bind_editor(self, id: &Id) -> Result<EditorSession, DomError> {
424        let mut eval = document::eval(
425            r#"const [id, base] = await dioxus.recv();
426const root = document.getElementById(id);
427if (!root) { dioxus.send(["missing", "", []]); return; }
428const { FutureCssEditor } = await import(`${base}/editor.js`);
429const editor = new FutureCssEditor({
430  root,
431  onChange: html => dioxus.send(["change", html, []]),
432  onSelectionChange: active => dioxus.send(["marks", "", Object.keys(active).filter(mark => active[mark])]),
433});
434dioxus.send(["ready", "", []]);
435while (true) {
436  const [op, html] = await dioxus.recv();
437  if (op === "destroy") { editor.destroy(); return; }
438  editor.value = html;
439}"#,
440        );
441        eval.send((id.as_str(), super::assets::BINDINGS_JS.to_string()))
442            .map_err(|_| DomError::Unsupported)?;
443        match eval
444            .recv::<(String, String, Vec<crate::models::rich_text::Mark>)>()
445            .await
446        {
447            Ok((kind, _, _)) if kind == "ready" => Ok(EditorSession(eval)),
448            Ok(_) => Err(DomError::MissingTarget),
449            Err(_) => Err(DomError::Unsupported),
450        }
451    }
452}