Skip to main content

euv_ui/component/layout/hook/
impl.rs

1use super::*;
2
3/// Implementation of layout functionality.
4///
5/// Provides methods for managing viewport resize, drawer toggle, and safe area.
6impl UseEuvLayout {
7    /// Creates a reactive signal that tracks whether the viewport is in mobile mode
8    /// and subscribes to browser `resize` events to keep it updated.
9    ///
10    /// The resize handler is debounced by `RESIZE_DEBOUNCE_MILLIS` (16ms) to avoid
11    /// excessive recomputation during continuous resize operations.
12    /// The listener is automatically removed when the hook context is cleared.
13    ///
14    /// # Returns
15    ///
16    /// - `Signal<bool>` - A reactive signal that is `true` when the viewport is mobile-sized.
17    pub fn use_resize() -> Signal<bool> {
18        let mobile_signal: Signal<bool> = App::use_signal(Router::is_mobile);
19        let timer_signal: Signal<Option<i32>> = App::use_signal(|| None);
20        let debounce_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
21            let mobile: bool = Router::is_mobile();
22            mobile_signal.set(mobile);
23        }));
24        let debounce_callback: Function = debounce_closure
25            .as_ref()
26            .unchecked_ref::<Function>()
27            .clone();
28        debounce_closure.forget();
29        let Some(timeout_window) = window() else {
30            return mobile_signal;
31        };
32        App::use_window_event(WINDOW_EVENT_RESIZE, move || {
33            let old_timer: Option<i32> = timer_signal.get();
34            if let Some(timer_id) = old_timer {
35                timeout_window.clear_timeout_with_handle(timer_id);
36            }
37            let new_timer: i32 = timeout_window
38                .set_timeout_with_callback_and_timeout_and_arguments_0(
39                    &debounce_callback,
40                    RESIZE_DEBOUNCE_MILLIS,
41                )
42                .unwrap_or_default();
43            timer_signal.set(Some(new_timer));
44        });
45        mobile_signal
46    }
47
48    /// Creates a click event handler that toggles the mobile nav drawer signal
49    /// with proper browser history management.
50    ///
51    /// When toggling from open to closed, calls `overlay_back` to remove the
52    /// extra history entry that was pushed when the drawer opened. When toggling
53    /// from closed to open, the `use_overlay_history` hook handles the
54    /// `pushState` call automatically.
55    ///
56    /// # Arguments
57    ///
58    /// - `Signal<bool>` - The boolean signal controlling the drawer visibility.
59    ///
60    /// # Returns
61    ///
62    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that toggles the drawer.
63    pub fn use_drawer_toggle(drawer_open: Signal<bool>) -> Option<Rc<dyn Fn(Event)>> {
64        Some(Rc::new(move |_: Event| {
65            let is_open: bool = drawer_open.get();
66            if is_open {
67                Router::overlay_stack_close();
68            }
69            drawer_open.set(!is_open);
70        }))
71    }
72
73    /// Returns whether the host platform is one that actually has a safe area
74    /// to reserve.
75    ///
76    /// This gate covers `--euv-safe-{right,bottom,left}` only. The top inset is
77    /// deliberately excluded: `--euv-mobile-safe-top` is injected by the euv app
78    /// and is not subject to this check, so filtering it by user agent would
79    /// lock out exactly the host it was added for — see
80    /// [`Self::init_safe_area_contract`].
81    ///
82    /// # Returns
83    ///
84    /// - `bool` - `true` when the host is iOS-family or desktop Safari.
85    pub(crate) fn needs_safe_area_insets() -> bool {
86        let Some(window_value) = window() else {
87            return false;
88        };
89        let user_agent: String = window_value.navigator().user_agent().unwrap_or_default();
90        needs_safe_area_insets(&user_agent)
91    }
92
93    /// Registers global event listeners that preserve `env(safe-area-inset-*)`
94    /// values after exiting any type of fullscreen on Android, and ensures that
95    /// the system back button exits native fullscreen instead of navigating away.
96    ///
97    /// On initialisation, reads the current `env(safe-area-inset-*)` pixel values
98    /// through a sentinel `<div>` and caches them in thread-local storage.
99    /// When a `fullscreenchange` or `resize` event fires, the cached values are
100    /// written directly as inline CSS custom properties on the real app root
101    /// element so that layout never depends on the potentially stale `env()`
102    /// function result.
103    ///
104    /// When a native (browser) fullscreen is entered — for example the user taps
105    /// the fullscreen button on a `<video controls>` element — a browser history
106    /// entry is added via `overlay_push_state` so that the system back gesture
107    /// will fire `popstate`. A `popstate` guard registered via
108    /// [`Router::register_popstate_guard`] then calls `document.exitFullscreen()` to leave
109    /// fullscreen, consuming the history entry without navigating to the previous
110    /// route. When the native fullscreen is exited through other means (e.g. the
111    /// browser's own exit button), the `fullscreenchange` handler consumes the
112    /// extra history entry via `overlay_back`.
113    ///
114    /// This hook should be called once during app initialization and covers:
115    /// - Native video fullscreen → exit via system back button
116    /// - CSS simulated fullscreen → exit (canvas drawing mode)
117    /// - Any future fullscreen scenarios
118    pub fn use_safe_area_fix() {
119        Self::cache_safe_area_insets();
120        Self::init_safe_area_contract();
121        App::use_window_event(WINDOW_EVENT_FULLSCREEN_CHANGE, || {
122            let Some(window_value) = window() else {
123                return;
124            };
125            let Some(document_value) = window_value.document() else {
126                return;
127            };
128            let is_fullscreen: bool = document_value.fullscreen_element().is_some();
129            if is_fullscreen {
130                NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(true));
131                Router::overlay_push_state();
132            } else {
133                let was_active: bool =
134                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get());
135                if was_active {
136                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(false));
137                    let exit_by_popstate: bool =
138                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.get());
139                    if exit_by_popstate {
140                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE
141                            .with(|flag: &Cell<bool>| flag.set(false));
142                    } else {
143                        Router::overlay_back(None);
144                    }
145                }
146                Self::apply_cached_insets();
147            }
148        });
149        App::use_window_event(WINDOW_EVENT_WEBKIT_FULLSCREEN_CHANGE, || {
150            Self::apply_cached_insets();
151        });
152        App::use_window_event(WINDOW_EVENT_RESIZE, || {
153            Self::apply_cached_insets();
154        });
155        Router::register_popstate_guard(Rc::new(|| {
156            if !NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get()) {
157                return false;
158            }
159            NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.set(true));
160            let Some(window_value) = window() else {
161                return false;
162            };
163            let Some(document_value) = window_value.document() else {
164                return false;
165            };
166            document_value.exit_fullscreen();
167            true
168        }));
169    }
170
171    /// Publishes the measured safe-area insets as CSS contract variables when
172    /// the host genuinely lays out edge-to-edge.
173    ///
174    /// The measured `env(safe-area-inset-*)` pixels are written to
175    /// `--euv-mobile-safe-top` and `--euv-safe-{right,bottom,left}` on `<html>`,
176    /// which `c_mobile_header`, `c_mobile_nav_drawer`, `c_app_root` and the
177    /// edge-anchored overlays consume as `var(--euv-safe-*, 0px)`. Every one of
178    /// those defaults to `0px`, so a browser host that letterboxes the page yet
179    /// reports non-zero insets renders flush against the screen edge instead of
180    /// reserving a blank band.
181    ///
182    /// Trust is decided by `is_edge_to_edge_viewport`, which combines the
183    /// explicit immersive declaration with the runtime
184    /// `screen.height - window.innerHeight` measurement. No platform check
185    /// applies here: the top inset is injected by the euv app for an immersive
186    /// Android WebView, so a host that declared itself is taken at its word.
187    fn init_safe_area_contract() {
188        if !is_edge_to_edge_viewport(Self::is_immersive_declared(), measure_viewport_screen_gap()) {
189            return;
190        }
191        let top_value: String =
192            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
193        if top_value.is_empty() {
194            return;
195        }
196        Self::apply_safe_area_contract(&top_value);
197    }
198
199    /// Writes the supplied measured top inset and the cached side insets onto
200    /// `<html>` as the safe-area contract variables.
201    ///
202    /// # Arguments
203    ///
204    /// - `&str` - The measured top safe-area pixel value.
205    fn apply_safe_area_contract(top_value: &str) {
206        let Some(window_value) = window() else {
207            return;
208        };
209        let Some(document_value) = window_value.document() else {
210            return;
211        };
212        let Some(root) = document_value.document_element() else {
213            return;
214        };
215        let root_element: HtmlElement = root.unchecked_into();
216        // The top inset is injected by the euv app and is trusted once the host
217        // declared itself edge-to-edge; the remaining sides additionally require
218        // a platform that actually reports them.
219        let sides_trusted: bool = Self::needs_safe_area_insets();
220        let right_value: String = safe_area_contract_value(
221            sides_trusted,
222            &SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone()),
223        );
224        let bottom_value: String = safe_area_contract_value(
225            sides_trusted,
226            &SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone()),
227        );
228        let left_value: String = safe_area_contract_value(
229            sides_trusted,
230            &SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone()),
231        );
232        let _: Result<(), JsValue> = root_element
233            .style()
234            .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, top_value);
235        let _: Result<(), JsValue> = root_element
236            .style()
237            .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
238        let _: Result<(), JsValue> = root_element
239            .style()
240            .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
241        let _: Result<(), JsValue> = root_element
242            .style()
243            .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
244    }
245
246    /// Returns whether the host environment declares immersive (edge-to-edge)
247    /// mode via `window.__EUV_IMMERSIVE__` or a
248    /// `<meta name="euv-immersive" content="true">` tag.
249    ///
250    /// # Returns
251    ///
252    /// - `bool` - `true` when immersive mode is declared by the host.
253    pub(crate) fn is_immersive_declared() -> bool {
254        let Some(window_value) = window() else {
255            return false;
256        };
257        let global_flag: bool = js_sys::Reflect::get(
258            &window_value,
259            &JsValue::from_str(IMMERSIVE_WINDOW_FLAG_PROPERTY),
260        )
261        .ok()
262        .and_then(|value: JsValue| value.as_bool())
263        .unwrap_or(false);
264        if global_flag {
265            return true;
266        }
267        window_value
268            .document()
269            .and_then(|document_value: Document| {
270                document_value
271                    .query_selector(IMMERSIVE_META_SELECTOR)
272                    .ok()
273                    .flatten()
274            })
275            .and_then(|meta: Element| meta.get_attribute(IMMERSIVE_META_CONTENT_ATTRIBUTE))
276            .map(|content: String| content == IMMERSIVE_META_CONTENT_ENABLED)
277            .unwrap_or(false)
278    }
279
280    /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
281    /// sentinel element and persists them in thread-local storage.
282    ///
283    /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
284    /// (and similarly for the other three sides). After forcing a layout
285    /// calculation, `getComputedStyle` yields the resolved pixel value, which is
286    /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
287    ///
288    /// If the top inset is empty or `0px` (i.e. no safe area on this device or
289    /// immersive mode not active), the values are not cached and no override is
290    /// applied.
291    fn cache_safe_area_insets() {
292        let top_cached: String =
293            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
294        if !top_cached.is_empty() {
295            return;
296        }
297        let Some(win) = window() else {
298            return;
299        };
300        let Some(document_value) = win.document() else {
301            return;
302        };
303        let Some(body) = document_value.body() else {
304            return;
305        };
306        let Ok(created_element) = document_value.create_element("div") else {
307            return;
308        };
309        let sentinel: HtmlElement = created_element.unchecked_into();
310        let _: Result<(), JsValue> = sentinel.style().set_property(
311            SENTINEL_STYLE_POSITION_PROPERTY,
312            SENTINEL_STYLE_POSITION_ABSOLUTE,
313        );
314        let _: Result<(), JsValue> = sentinel.style().set_property(
315            SENTINEL_STYLE_VISIBILITY_PROPERTY,
316            SENTINEL_STYLE_VISIBILITY_HIDDEN,
317        );
318        let _: Result<(), JsValue> = sentinel.style().set_property(
319            SENTINEL_STYLE_POINTER_EVENTS_PROPERTY,
320            SENTINEL_STYLE_POINTER_EVENTS_NONE,
321        );
322        let _: Result<(), JsValue> = sentinel
323            .style()
324            .set_property(SAFE_AREA_PADDING_TOP_PROPERTY, SAFE_AREA_PADDING_TOP_VALUE);
325        let _: Result<(), JsValue> = sentinel.style().set_property(
326            SAFE_AREA_PADDING_RIGHT_PROPERTY,
327            SAFE_AREA_PADDING_RIGHT_VALUE,
328        );
329        let _: Result<(), JsValue> = sentinel.style().set_property(
330            SAFE_AREA_PADDING_BOTTOM_PROPERTY,
331            SAFE_AREA_PADDING_BOTTOM_VALUE,
332        );
333        let _: Result<(), JsValue> = sentinel.style().set_property(
334            SAFE_AREA_PADDING_LEFT_PROPERTY,
335            SAFE_AREA_PADDING_LEFT_VALUE,
336        );
337        let _: Result<Node, JsValue> = body.append_child(&sentinel);
338        let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
339            let _: Result<Node, JsValue> = body.remove_child(&sentinel);
340            return;
341        };
342        let top_value: String = computed
343            .get_property_value(SAFE_AREA_PADDING_TOP_PROPERTY)
344            .unwrap_or_default();
345        let right_value: String = computed
346            .get_property_value(SAFE_AREA_PADDING_RIGHT_PROPERTY)
347            .unwrap_or_default();
348        let bottom_value: String = computed
349            .get_property_value(SAFE_AREA_PADDING_BOTTOM_PROPERTY)
350            .unwrap_or_default();
351        let left_value: String = computed
352            .get_property_value(SAFE_AREA_PADDING_LEFT_PROPERTY)
353            .unwrap_or_default();
354        let _: Result<Node, JsValue> = body.remove_child(&sentinel);
355        if top_value.is_empty() || top_value == "0px" {
356            return;
357        }
358        SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
359        SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
360        SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
361        SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
362    }
363
364    /// Writes the cached safe-area inset values as inline CSS custom properties
365    /// on the real app root element and any fullscreen overlay containers.
366    ///
367    /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
368    /// `c_mobile_nav_drawer`, `c_canvas_container_fullscreen`, and
369    /// `c_game_container_fullscreen` consume `var(--safe-area-inset-top)`
370    /// in their `padding` declarations. By overriding these CSS custom
371    /// properties with inline style (which has higher specificity than the
372    /// stylesheet rule from `vars!`), all `var()` references resolve to the
373    /// cached pixel values, bypassing the stale `env()` function after a
374    /// fullscreen exit.
375    ///
376    /// The canvas and game fullscreen containers are `position: fixed`
377    /// and outside the app root subtree, so they do not inherit the inline
378    /// overrides — they must be patched separately.
379    pub fn apply_cached_insets() {
380        let raw_top: String =
381            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
382        let raw_right: String =
383            SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
384        let raw_bottom: String =
385            SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
386        let raw_left: String =
387            SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
388        if raw_top.is_empty()
389            && raw_right.is_empty()
390            && raw_bottom.is_empty()
391            && raw_left.is_empty()
392        {
393            return;
394        }
395        let is_trusted: bool =
396            is_edge_to_edge_viewport(Self::is_immersive_declared(), measure_viewport_screen_gap());
397        let top_value: String = safe_area_contract_value(is_trusted, &raw_top);
398        let sides_trusted: bool = is_trusted && Self::needs_safe_area_insets();
399        let right_value: String = safe_area_contract_value(sides_trusted, &raw_right);
400        let bottom_value: String = safe_area_contract_value(sides_trusted, &raw_bottom);
401        let left_value: String = safe_area_contract_value(sides_trusted, &raw_left);
402        let Some(window_value) = window() else {
403            return;
404        };
405        let Some(document_value) = window_value.document() else {
406            return;
407        };
408        let apply_to: &dyn Fn(&HtmlElement) = &|element: &HtmlElement| {
409            let _: Result<(), JsValue> = element
410                .style()
411                .set_property(SAFE_AREA_INSET_TOP_PROPERTY, &top_value);
412            let _: Result<(), JsValue> = element
413                .style()
414                .set_property(SAFE_AREA_INSET_RIGHT_PROPERTY, &right_value);
415            let _: Result<(), JsValue> = element
416                .style()
417                .set_property(SAFE_AREA_INSET_BOTTOM_PROPERTY, &bottom_value);
418            let _: Result<(), JsValue> = element
419                .style()
420                .set_property(SAFE_AREA_INSET_LEFT_PROPERTY, &left_value);
421            let _: Result<(), JsValue> = element
422                .style()
423                .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, &top_value);
424            let _: Result<(), JsValue> = element
425                .style()
426                .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
427            let _: Result<(), JsValue> = element
428                .style()
429                .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
430            let _: Result<(), JsValue> = element
431                .style()
432                .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
433        };
434        if let Some(app_root) = document_value
435            .query_selector(APP_ROOT_MOBILE_SELECTOR)
436            .ok()
437            .flatten()
438            .map(|element: Element| element.unchecked_into::<HtmlElement>())
439            .or_else(|| {
440                document_value
441                    .query_selector(APP_ROOT_DESKTOP_SELECTOR)
442                    .ok()
443                    .flatten()
444                    .map(|element: Element| element.unchecked_into::<HtmlElement>())
445            })
446        {
447            apply_to(&app_root);
448        }
449        if let Some(canvas_fullscreen) = document_value
450            .query_selector(FULLSCREEN_CANVAS_CONTAINER_SELECTOR)
451            .ok()
452            .flatten()
453            .map(|element: Element| element.unchecked_into::<HtmlElement>())
454        {
455            apply_to(&canvas_fullscreen);
456        }
457        if let Some(game_fullscreen) = document_value
458            .query_selector(FULLSCREEN_GAME_CONTAINER_SELECTOR)
459            .ok()
460            .flatten()
461            .map(|element: Element| element.unchecked_into::<HtmlElement>())
462        {
463            apply_to(&game_fullscreen);
464        }
465    }
466}