Skip to main content

logicaffeine_web/ui/hooks/
use_device_pixel_grid.rs

1//! Where the screen's pixels fall under an element: how large one device pixel is, and how far the
2//! element's box starts past the device-pixel grid, kept current as the page zooms, moves to a
3//! screen of another density, or lays the element out anew.
4
5use dioxus::prelude::*;
6
7/// One device pixel in CSS pixels, and the nudge that puts an element's content on the
8/// device-pixel grid.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct PixelGrid {
11    /// One device pixel, in CSS pixels (`1 / devicePixelRatio`).
12    pub device_px: f64,
13    /// How far right, in CSS pixels, the element's content must move for its left edge to land on
14    /// a device pixel: at least 0 and under one device pixel.
15    pub nudge_x: f64,
16    /// How far down, likewise, for its top edge.
17    pub nudge_y: f64,
18}
19
20impl PixelGrid {
21    /// The grid assumed where there is no window to measure (the server render): CSS pixels, no
22    /// nudge.
23    pub const UNMEASURED: PixelGrid = PixelGrid { device_px: 1.0, nudge_x: 0.0, nudge_y: 0.0 };
24
25    /// A corner closer than this to a device pixel's edge, in device pixels, is on it. Layout keeps
26    /// positions in its own units, so a corner meant for a grid line can land a hair past it, and a
27    /// nudge of almost a whole pixel would only shift the content.
28    pub const ON_GRID: f64 = 1e-3;
29
30    /// The grid at device pixel ratio `ratio` under a box whose top-left corner sits at (`left`,
31    /// `top`) CSS pixels in the viewport.
32    pub fn at(ratio: f64, left: f64, top: f64) -> PixelGrid {
33        if !(ratio.is_finite() && ratio > 0.0) {
34            return Self::UNMEASURED;
35        }
36        let nudge = |at: f64| {
37            let past = (at * ratio).rem_euclid(1.0);
38            if !past.is_finite() || past < Self::ON_GRID || past > 1.0 - Self::ON_GRID {
39                0.0
40            } else {
41                (1.0 - past) / ratio
42            }
43        };
44        PixelGrid { device_px: 1.0 / ratio, nudge_x: nudge(left), nudge_y: nudge(top) }
45    }
46}
47
48/// The device-pixel grid under the element given the returned `onmounted` handler, updated for as
49/// long as the calling component is mounted.
50///
51/// The element's layout is watched by a `ResizeObserver`, which reports the box once it is laid out
52/// and again whenever it changes size — the window resized, the page zoomed, or the layout around
53/// it moved it. A move to a screen of another density changes the ratio alone, which only a
54/// `resolution` media query reports; that query is re-armed for each new ratio. An element inside
55/// a scrolling column also moves without changing size — the column scrolls, or what sits above it
56/// in the column grows or shrinks — so the column's scroll and every change to its content are
57/// watched too.
58pub fn use_device_pixel_grid() -> (Signal<PixelGrid>, Callback<MountedEvent>) {
59    #[cfg(target_arch = "wasm32")]
60    {
61        use std::cell::{Cell, RefCell};
62        use std::rc::Rc;
63        use wasm_bindgen::prelude::*;
64        use wasm_bindgen::JsCast;
65
66        /// What the grid is read from, and the watchers that say when to read it again.
67        #[derive(Default)]
68        struct Watch {
69            element: RefCell<Option<web_sys::Element>>,
70            observer: RefCell<Option<web_sys::ResizeObserver>>,
71            /// The resolution query armed on the ratio last measured, which changes when the ratio
72            /// leaves it.
73            query: RefCell<Option<web_sys::MediaQueryList>>,
74            armed: Cell<f64>,
75            refresh: RefCell<Option<js_sys::Function>>,
76            /// The scrolling column the element sits in, if any, and the watcher on its content.
77            column: RefCell<Option<web_sys::Element>>,
78            mutations: RefCell<Option<web_sys::MutationObserver>>,
79        }
80
81        impl Watch {
82            /// The page's ratio, and the grid under the element (under the viewport's corner until
83            /// the element is mounted).
84            fn measure(&self) -> Option<(f64, PixelGrid)> {
85                let ratio = web_sys::window()?.device_pixel_ratio();
86                let (left, top) = self.element.borrow().as_ref().map_or((0.0, 0.0), |element| {
87                    let rect = element.get_bounding_client_rect();
88                    (rect.left(), rect.top())
89                });
90                Some((ratio, PixelGrid::at(ratio, left, top)))
91            }
92
93            fn arm(&self, ratio: f64) {
94                if self.armed.get() == ratio && self.query.borrow().is_some() {
95                    return;
96                }
97                self.disarm();
98                let refresh = self.refresh.borrow();
99                let Some(refresh) = refresh.as_ref() else { return };
100                let query = web_sys::window().and_then(|w| w.match_media(&format!("(resolution: {ratio}dppx)")).ok().flatten());
101                if let Some(query) = query {
102                    let _ = query.add_event_listener_with_callback("change", refresh);
103                    *self.query.borrow_mut() = Some(query);
104                    self.armed.set(ratio);
105                }
106            }
107
108            fn disarm(&self) {
109                if let (Some(query), Some(refresh)) = (self.query.borrow_mut().take(), self.refresh.borrow().as_ref()) {
110                    let _ = query.remove_event_listener_with_callback("change", refresh);
111                }
112            }
113
114            fn observe(&self, element: web_sys::Element) {
115                if let Some(observer) = self.observer.borrow().as_ref() {
116                    if let Some(previous) = self.element.borrow().as_ref() {
117                        observer.unobserve(previous);
118                    }
119                    let options = web_sys::ResizeObserverOptions::new();
120                    options.set_box(web_sys::ResizeObserverBoxOptions::BorderBox);
121                    observer.observe_with_options(&element, &options);
122                }
123                self.follow_column(&element);
124                *self.element.borrow_mut() = Some(element);
125            }
126
127            /// Watch the scrolling column around `element`: its scroll, and any change to what it
128            /// holds, move the element without resizing it.
129            fn follow_column(&self, element: &web_sys::Element) {
130                self.unfollow_column();
131                let Some(window) = web_sys::window() else { return };
132                let refresh = self.refresh.borrow();
133                let Some(refresh) = refresh.as_ref() else { return };
134                let scrolls = |el: &web_sys::Element| {
135                    window.get_computed_style(el).ok().flatten().is_some_and(|style| {
136                        let overflow = style.get_property_value("overflow-y").unwrap_or_default();
137                        overflow == "auto" || overflow == "scroll"
138                    })
139                };
140                let mut column = element.parent_element();
141                while let Some(el) = column.as_ref() {
142                    if scrolls(el) {
143                        break;
144                    }
145                    column = el.parent_element();
146                }
147                let Some(column) = column else { return };
148                let _ = column.add_event_listener_with_callback("scroll", refresh);
149                if let Ok(mutations) = web_sys::MutationObserver::new(refresh) {
150                    let options = web_sys::MutationObserverInit::new();
151                    options.set_subtree(true);
152                    options.set_child_list(true);
153                    options.set_attributes(true);
154                    options.set_character_data(true);
155                    let _ = mutations.observe_with_options(&column, &options);
156                    *self.mutations.borrow_mut() = Some(mutations);
157                }
158                *self.column.borrow_mut() = Some(column);
159            }
160
161            fn unfollow_column(&self) {
162                if let Some(mutations) = self.mutations.borrow_mut().take() {
163                    mutations.disconnect();
164                }
165                if let (Some(column), Some(refresh)) = (self.column.borrow_mut().take(), self.refresh.borrow().as_ref()) {
166                    let _ = column.remove_event_listener_with_callback("scroll", refresh);
167                }
168            }
169
170            fn stop(&self) {
171                self.disarm();
172                self.unfollow_column();
173                if let Some(observer) = self.observer.borrow_mut().take() {
174                    observer.disconnect();
175                }
176                self.refresh.borrow_mut().take();
177                self.element.borrow_mut().take();
178            }
179        }
180
181        let mut grid = use_signal(|| {
182            web_sys::window().map_or(PixelGrid::UNMEASURED, |w| PixelGrid::at(w.device_pixel_ratio(), 0.0, 0.0))
183        });
184        let (watch, refresh) = use_hook(move || {
185            let watch = Rc::new(Watch::default());
186            let refresh = {
187                let watch = Rc::clone(&watch);
188                Closure::<dyn FnMut()>::new(move || {
189                    let Some((ratio, now)) = watch.measure() else { return };
190                    watch.arm(ratio);
191                    if *grid.peek() != now {
192                        grid.set(now);
193                    }
194                })
195            };
196            let function = refresh.as_ref().unchecked_ref::<js_sys::Function>().clone();
197            *watch.observer.borrow_mut() = web_sys::ResizeObserver::new(&function).ok();
198            *watch.refresh.borrow_mut() = Some(function);
199            if let Some(window) = web_sys::window() {
200                watch.arm(window.device_pixel_ratio());
201            }
202            (watch, Rc::new(refresh))
203        });
204        let on_mounted = use_callback({
205            let watch = Rc::clone(&watch);
206            move |event: MountedEvent| {
207                if let Some(element) = event.data().downcast::<web_sys::Element>().cloned() {
208                    watch.observe(element);
209                }
210            }
211        });
212        use_drop(move || {
213            watch.stop();
214            drop(refresh);
215        });
216        (grid, on_mounted)
217    }
218    #[cfg(not(target_arch = "wasm32"))]
219    {
220        (use_signal(|| PixelGrid::UNMEASURED), use_callback(|_: MountedEvent| {}))
221    }
222}
223
224#[cfg(test)]
225#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/hooks/use_device_pixel_grid/tests.rs"]
226mod tests;