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    /// Every write path for the contract variables goes through this first, so
77    /// a platform without a notch, a rounded corner or a home indicator never
78    /// reserves space for one — not even when the host declares immersive mode
79    /// or reports a flush viewport.
80    ///
81    /// # Returns
82    ///
83    /// - `bool` - `true` when the host is iOS-family or desktop Safari.
84    pub(crate) fn needs_safe_area_insets() -> bool {
85        let Some(window_value) = window() else {
86            return false;
87        };
88        let user_agent: String = window_value.navigator().user_agent().unwrap_or_default();
89        needs_safe_area_insets(&user_agent)
90    }
91
92    /// Registers global event listeners that preserve `env(safe-area-inset-*)`
93    /// values after exiting any type of fullscreen on Android, and ensures that
94    /// the system back button exits native fullscreen instead of navigating away.
95    ///
96    /// On initialisation, reads the current `env(safe-area-inset-*)` pixel values
97    /// through a sentinel `<div>` and caches them in thread-local storage.
98    /// When a `fullscreenchange` or `resize` event fires, the cached values are
99    /// written directly as inline CSS custom properties on the real app root
100    /// element so that layout never depends on the potentially stale `env()`
101    /// function result.
102    ///
103    /// When a native (browser) fullscreen is entered — for example the user taps
104    /// the fullscreen button on a `<video controls>` element — a browser history
105    /// entry is added via `overlay_push_state` so that the system back gesture
106    /// will fire `popstate`. A `popstate` guard registered via
107    /// [`Router::register_popstate_guard`] then calls `document.exitFullscreen()` to leave
108    /// fullscreen, consuming the history entry without navigating to the previous
109    /// route. When the native fullscreen is exited through other means (e.g. the
110    /// browser's own exit button), the `fullscreenchange` handler consumes the
111    /// extra history entry via `overlay_back`.
112    ///
113    /// This hook should be called once during app initialization and covers:
114    /// - Native video fullscreen → exit via system back button
115    /// - CSS simulated fullscreen → exit (canvas drawing mode)
116    /// - Any future fullscreen scenarios
117    pub fn use_safe_area_fix() {
118        Self::cache_safe_area_insets();
119        Self::init_safe_area_contract();
120        App::use_window_event(WINDOW_EVENT_FULLSCREEN_CHANGE, || {
121            let Some(window_value) = window() else {
122                return;
123            };
124            let Some(document_value) = window_value.document() else {
125                return;
126            };
127            let is_fullscreen: bool = document_value.fullscreen_element().is_some();
128            if is_fullscreen {
129                NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(true));
130                Router::overlay_push_state();
131            } else {
132                let was_active: bool =
133                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get());
134                if was_active {
135                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(false));
136                    let exit_by_popstate: bool =
137                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.get());
138                    if exit_by_popstate {
139                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE
140                            .with(|flag: &Cell<bool>| flag.set(false));
141                    } else {
142                        Router::overlay_back(None);
143                    }
144                }
145                Self::apply_cached_insets();
146            }
147        });
148        App::use_window_event(WINDOW_EVENT_WEBKIT_FULLSCREEN_CHANGE, || {
149            Self::apply_cached_insets();
150        });
151        App::use_window_event(WINDOW_EVENT_RESIZE, || {
152            Self::apply_cached_insets();
153        });
154        Router::register_popstate_guard(Rc::new(|| {
155            if !NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get()) {
156                return false;
157            }
158            NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.set(true));
159            let Some(window_value) = window() else {
160                return false;
161            };
162            let Some(document_value) = window_value.document() else {
163                return false;
164            };
165            document_value.exit_fullscreen();
166            true
167        }));
168    }
169
170    /// Publishes the measured safe-area insets as CSS contract variables when
171    /// the host genuinely lays out edge-to-edge.
172    ///
173    /// The measured `env(safe-area-inset-*)` pixels are written to
174    /// `--euv-mobile-safe-top` and `--euv-safe-{right,bottom,left}` on `<html>`,
175    /// which `c_mobile_header`, `c_mobile_nav_drawer`, `c_app_root` and the
176    /// edge-anchored overlays consume as `var(--euv-safe-*, 0px)`. Every one of
177    /// those defaults to `0px`, so a browser host that letterboxes the page yet
178    /// reports non-zero insets renders flush against the screen edge instead of
179    /// reserving a blank band.
180    ///
181    /// Trust is decided by `is_edge_to_edge_viewport`, which combines the
182    /// explicit immersive declaration with the runtime
183    /// `screen.height - window.innerHeight` measurement.
184    fn init_safe_area_contract() {
185        if !Self::needs_safe_area_insets() {
186            return;
187        }
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        let right_value: String = safe_area_contract_value(
217            true,
218            &SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone()),
219        );
220        let bottom_value: String = safe_area_contract_value(
221            true,
222            &SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone()),
223        );
224        let left_value: String = safe_area_contract_value(
225            true,
226            &SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone()),
227        );
228        let _: Result<(), JsValue> = root_element
229            .style()
230            .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, top_value);
231        let _: Result<(), JsValue> = root_element
232            .style()
233            .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
234        let _: Result<(), JsValue> = root_element
235            .style()
236            .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
237        let _: Result<(), JsValue> = root_element
238            .style()
239            .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
240    }
241
242    /// Returns whether the host environment declares immersive (edge-to-edge)
243    /// mode via `window.__EUV_IMMERSIVE__` or a
244    /// `<meta name="euv-immersive" content="true">` tag.
245    ///
246    /// # Returns
247    ///
248    /// - `bool` - `true` when immersive mode is declared by the host.
249    pub(crate) fn is_immersive_declared() -> bool {
250        let Some(window_value) = window() else {
251            return false;
252        };
253        let global_flag: bool = js_sys::Reflect::get(
254            &window_value,
255            &JsValue::from_str(IMMERSIVE_WINDOW_FLAG_PROPERTY),
256        )
257        .ok()
258        .and_then(|value: JsValue| value.as_bool())
259        .unwrap_or(false);
260        if global_flag {
261            return true;
262        }
263        window_value
264            .document()
265            .and_then(|document_value: Document| {
266                document_value
267                    .query_selector(IMMERSIVE_META_SELECTOR)
268                    .ok()
269                    .flatten()
270            })
271            .and_then(|meta: Element| meta.get_attribute(IMMERSIVE_META_CONTENT_ATTRIBUTE))
272            .map(|content: String| content == IMMERSIVE_META_CONTENT_ENABLED)
273            .unwrap_or(false)
274    }
275
276    /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
277    /// sentinel element and persists them in thread-local storage.
278    ///
279    /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
280    /// (and similarly for the other three sides). After forcing a layout
281    /// calculation, `getComputedStyle` yields the resolved pixel value, which is
282    /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
283    ///
284    /// If the top inset is empty or `0px` (i.e. no safe area on this device or
285    /// immersive mode not active), the values are not cached and no override is
286    /// applied.
287    fn cache_safe_area_insets() {
288        let top_cached: String =
289            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
290        if !top_cached.is_empty() {
291            return;
292        }
293        let Some(win) = window() else {
294            return;
295        };
296        let Some(document_value) = win.document() else {
297            return;
298        };
299        let Some(body) = document_value.body() else {
300            return;
301        };
302        let Ok(created_element) = document_value.create_element("div") else {
303            return;
304        };
305        let sentinel: HtmlElement = created_element.unchecked_into();
306        let _: Result<(), JsValue> = sentinel.style().set_property(
307            SENTINEL_STYLE_POSITION_PROPERTY,
308            SENTINEL_STYLE_POSITION_ABSOLUTE,
309        );
310        let _: Result<(), JsValue> = sentinel.style().set_property(
311            SENTINEL_STYLE_VISIBILITY_PROPERTY,
312            SENTINEL_STYLE_VISIBILITY_HIDDEN,
313        );
314        let _: Result<(), JsValue> = sentinel.style().set_property(
315            SENTINEL_STYLE_POINTER_EVENTS_PROPERTY,
316            SENTINEL_STYLE_POINTER_EVENTS_NONE,
317        );
318        let _: Result<(), JsValue> = sentinel
319            .style()
320            .set_property(SAFE_AREA_PADDING_TOP_PROPERTY, SAFE_AREA_PADDING_TOP_VALUE);
321        let _: Result<(), JsValue> = sentinel.style().set_property(
322            SAFE_AREA_PADDING_RIGHT_PROPERTY,
323            SAFE_AREA_PADDING_RIGHT_VALUE,
324        );
325        let _: Result<(), JsValue> = sentinel.style().set_property(
326            SAFE_AREA_PADDING_BOTTOM_PROPERTY,
327            SAFE_AREA_PADDING_BOTTOM_VALUE,
328        );
329        let _: Result<(), JsValue> = sentinel.style().set_property(
330            SAFE_AREA_PADDING_LEFT_PROPERTY,
331            SAFE_AREA_PADDING_LEFT_VALUE,
332        );
333        let _: Result<Node, JsValue> = body.append_child(&sentinel);
334        let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
335            let _: Result<Node, JsValue> = body.remove_child(&sentinel);
336            return;
337        };
338        let top_value: String = computed
339            .get_property_value(SAFE_AREA_PADDING_TOP_PROPERTY)
340            .unwrap_or_default();
341        let right_value: String = computed
342            .get_property_value(SAFE_AREA_PADDING_RIGHT_PROPERTY)
343            .unwrap_or_default();
344        let bottom_value: String = computed
345            .get_property_value(SAFE_AREA_PADDING_BOTTOM_PROPERTY)
346            .unwrap_or_default();
347        let left_value: String = computed
348            .get_property_value(SAFE_AREA_PADDING_LEFT_PROPERTY)
349            .unwrap_or_default();
350        let _: Result<Node, JsValue> = body.remove_child(&sentinel);
351        if top_value.is_empty() || top_value == "0px" {
352            return;
353        }
354        SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
355        SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
356        SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
357        SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
358    }
359
360    /// Writes the cached safe-area inset values as inline CSS custom properties
361    /// on the real app root element and any fullscreen overlay containers.
362    ///
363    /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
364    /// `c_mobile_nav_drawer`, `c_canvas_container_fullscreen`, and
365    /// `c_game_container_fullscreen` consume `var(--safe-area-inset-top)`
366    /// in their `padding` declarations. By overriding these CSS custom
367    /// properties with inline style (which has higher specificity than the
368    /// stylesheet rule from `vars!`), all `var()` references resolve to the
369    /// cached pixel values, bypassing the stale `env()` function after a
370    /// fullscreen exit.
371    ///
372    /// The canvas and game fullscreen containers are `position: fixed`
373    /// and outside the app root subtree, so they do not inherit the inline
374    /// overrides — they must be patched separately.
375    pub fn apply_cached_insets() {
376        let raw_top: String =
377            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
378        let raw_right: String =
379            SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
380        let raw_bottom: String =
381            SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
382        let raw_left: String =
383            SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
384        if raw_top.is_empty()
385            && raw_right.is_empty()
386            && raw_bottom.is_empty()
387            && raw_left.is_empty()
388        {
389            return;
390        }
391        let is_trusted: bool = Self::needs_safe_area_insets()
392            && is_edge_to_edge_viewport(
393                Self::is_immersive_declared(),
394                measure_viewport_screen_gap(),
395            );
396        let top_value: String = safe_area_contract_value(is_trusted, &raw_top);
397        let right_value: String = safe_area_contract_value(is_trusted, &raw_right);
398        let bottom_value: String = safe_area_contract_value(is_trusted, &raw_bottom);
399        let left_value: String = safe_area_contract_value(is_trusted, &raw_left);
400        let Some(window_value) = window() else {
401            return;
402        };
403        let Some(document_value) = window_value.document() else {
404            return;
405        };
406        let apply_to: &dyn Fn(&HtmlElement) = &|element: &HtmlElement| {
407            let _: Result<(), JsValue> = element
408                .style()
409                .set_property(SAFE_AREA_INSET_TOP_PROPERTY, &top_value);
410            let _: Result<(), JsValue> = element
411                .style()
412                .set_property(SAFE_AREA_INSET_RIGHT_PROPERTY, &right_value);
413            let _: Result<(), JsValue> = element
414                .style()
415                .set_property(SAFE_AREA_INSET_BOTTOM_PROPERTY, &bottom_value);
416            let _: Result<(), JsValue> = element
417                .style()
418                .set_property(SAFE_AREA_INSET_LEFT_PROPERTY, &left_value);
419            let _: Result<(), JsValue> = element
420                .style()
421                .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, &top_value);
422            let _: Result<(), JsValue> = element
423                .style()
424                .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
425            let _: Result<(), JsValue> = element
426                .style()
427                .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
428            let _: Result<(), JsValue> = element
429                .style()
430                .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
431        };
432        if let Some(app_root) = document_value
433            .query_selector(APP_ROOT_MOBILE_SELECTOR)
434            .ok()
435            .flatten()
436            .map(|element: Element| element.unchecked_into::<HtmlElement>())
437            .or_else(|| {
438                document_value
439                    .query_selector(APP_ROOT_DESKTOP_SELECTOR)
440                    .ok()
441                    .flatten()
442                    .map(|element: Element| element.unchecked_into::<HtmlElement>())
443            })
444        {
445            apply_to(&app_root);
446        }
447        if let Some(canvas_fullscreen) = document_value
448            .query_selector(FULLSCREEN_CANVAS_CONTAINER_SELECTOR)
449            .ok()
450            .flatten()
451            .map(|element: Element| element.unchecked_into::<HtmlElement>())
452        {
453            apply_to(&canvas_fullscreen);
454        }
455        if let Some(game_fullscreen) = document_value
456            .query_selector(FULLSCREEN_GAME_CONTAINER_SELECTOR)
457            .ok()
458            .flatten()
459            .map(|element: Element| element.unchecked_into::<HtmlElement>())
460        {
461            apply_to(&game_fullscreen);
462        }
463    }
464}