logicaffeine_web/ui/hooks/
use_device_pixel_grid.rs1use dioxus::prelude::*;
6
7#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct PixelGrid {
11 pub device_px: f64,
13 pub nudge_x: f64,
16 pub nudge_y: f64,
18}
19
20impl PixelGrid {
21 pub const UNMEASURED: PixelGrid = PixelGrid { device_px: 1.0, nudge_x: 0.0, nudge_y: 0.0 };
24
25 pub const ON_GRID: f64 = 1e-3;
29
30 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
48pub 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 #[derive(Default)]
68 struct Watch {
69 element: RefCell<Option<web_sys::Element>>,
70 observer: RefCell<Option<web_sys::ResizeObserver>>,
71 query: RefCell<Option<web_sys::MediaQueryList>>,
74 armed: Cell<f64>,
75 refresh: RefCell<Option<js_sys::Function>>,
76 column: RefCell<Option<web_sys::Element>>,
78 mutations: RefCell<Option<web_sys::MutationObserver>>,
79 }
80
81 impl Watch {
82 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 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;