Skip to main content

compose_rust/
window.rs

1//! The window's measured size, and the hook components read it through.
2//!
3//! The UI is authored here but measured by the Renderer, so the only way a Rust component
4//! can know how wide the window is, is for the Renderer to tell it. The Renderer sends one
5//! event when the size class changes and nothing in between, so this module is a single
6//! current value plus the list of components that asked to be told when it changes.
7
8use crate::schema::{WindowHeightClass, WindowSizeClass};
9use std::cell::{Cell, RefCell};
10use std::rc::Rc;
11use std::sync::Arc;
12
13/// The window's size, as the Renderer last measured it.
14///
15/// `width_dp` and `height_dp` are density-independent pixels, the same unit gesture
16/// coordinates use. They are the measurements taken at the moment the class last changed,
17/// not a value that follows every pixel of a drag: a size that changed every layout pass
18/// would run the VirtualDom every layout pass.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct WindowSize {
21    pub width_dp: f32,
22    pub height_dp: f32,
23    pub class: WindowSizeClass,
24    pub height_class: WindowHeightClass,
25}
26
27impl WindowSize {
28    pub const fn new(
29        width_dp: f32,
30        height_dp: f32,
31        class: WindowSizeClass,
32        height_class: WindowHeightClass,
33    ) -> Self {
34        Self {
35            width_dp,
36            height_dp,
37            class,
38            height_class,
39        }
40    }
41
42    /// Phone-shaped: narrower than 600dp. One column.
43    pub fn is_compact(&self) -> bool {
44        matches!(self.class, WindowSizeClass::Compact)
45    }
46
47    /// Tablet-shaped: 600dp to 840dp.
48    pub fn is_medium(&self) -> bool {
49        matches!(self.class, WindowSizeClass::Medium)
50    }
51
52    /// Desktop-shaped: 840dp and wider. Room for a permanent sidebar.
53    pub fn is_expanded(&self) -> bool {
54        matches!(self.class, WindowSizeClass::Expanded)
55    }
56
57    /// Short: under 480dp tall. A phone lying on its side is the usual one, and a column
58    /// of stacked sections is what has to fold there.
59    pub fn is_short(&self) -> bool {
60        matches!(self.height_class, WindowHeightClass::Compact)
61    }
62
63    /// The ordinary height: 480dp to 900dp.
64    pub fn is_medium_height(&self) -> bool {
65        matches!(self.height_class, WindowHeightClass::Medium)
66    }
67
68    /// Tall: 900dp and more. A portrait tablet, or a window someone stretched.
69    pub fn is_tall(&self) -> bool {
70        matches!(self.height_class, WindowHeightClass::Expanded)
71    }
72}
73
74impl Default for WindowSize {
75    /// What a component sees before the Renderer has measured anything.
76    ///
77    /// Compact rather than a wider guess: a one-column layout is usable at any width, so
78    /// the single render that happens before the first measurement arrives is never
79    /// broken, only narrower than it needs to be.
80    fn default() -> Self {
81        Self::new(
82            0.0,
83            0.0,
84            WindowSizeClass::Compact,
85            WindowHeightClass::Compact,
86        )
87    }
88}
89
90struct Subscriber {
91    id: u64,
92    notify: Arc<dyn Fn() + Send + Sync>,
93}
94
95thread_local! {
96    /// What each observed node last measured, by the token its screen gave it.
97    ///
98    /// A map rather than a field, because nothing is in it until a screen asks: a tree
99    /// with no observers keeps an empty map and pays for nothing.
100    static NODES: RefCell<Vec<(u32, WindowSize)>> = const { RefCell::new(Vec::new()) };
101    static NODE_SUBSCRIBERS: RefCell<Vec<(u32, Subscriber)>> = const { RefCell::new(Vec::new()) };
102    static CURRENT: Cell<WindowSize> = const { Cell::new(WindowSize {
103        width_dp: 0.0,
104        height_dp: 0.0,
105        class: WindowSizeClass::Compact,
106        height_class: WindowHeightClass::Compact,
107    }) };
108    static SUBSCRIBERS: RefCell<Vec<Subscriber>> = const { RefCell::new(Vec::new()) };
109    static NEXT_SUBSCRIBER_ID: Cell<u64> = const { Cell::new(1) };
110}
111
112/// The size the Renderer last reported. Available outside a component too.
113pub fn window_size() -> WindowSize {
114    CURRENT.with(Cell::get)
115}
116
117/// Records a new measurement and wakes the components that asked about it.
118///
119/// Returns whether anything changed. Nothing is allocated here: the subscriber list is
120/// walked in place, and waking a component only marks its scope dirty.
121pub(crate) fn publish(size: WindowSize) -> bool {
122    if CURRENT.with(Cell::get) == size {
123        return false;
124    }
125    CURRENT.with(|current| current.set(size));
126    SUBSCRIBERS.with_borrow(|subscribers| {
127        for subscriber in subscribers {
128            (subscriber.notify)();
129        }
130    });
131    true
132}
133
134/// The size a node last measured, or a zero size for one nothing has reported yet.
135pub fn node_size(token: u32) -> WindowSize {
136    NODES.with_borrow(|nodes| {
137        nodes
138            .iter()
139            .find(|(name, _)| *name == token)
140            .map(|(_, size)| *size)
141            .unwrap_or_default()
142    })
143}
144
145/// Records a node's measurement and wakes whoever asked about that one.
146///
147/// Returns whether anything was woken, so a report about a node nobody is reading costs
148/// a lookup and nothing else.
149pub(crate) fn publish_node(token: u32, size: WindowSize) -> bool {
150    let changed =
151        NODES.with_borrow_mut(
152            |nodes| match nodes.iter_mut().find(|(name, _)| *name == token) {
153                Some(entry) => {
154                    if entry.1 == size {
155                        return false;
156                    }
157                    entry.1 = size;
158                    true
159                }
160                None => {
161                    nodes.push((token, size));
162                    true
163                }
164            },
165        );
166    if !changed {
167        return false;
168    }
169    NODE_SUBSCRIBERS.with_borrow(|subscribers| {
170        let mut woke = false;
171        for (name, subscriber) in subscribers {
172            if *name == token {
173                (subscriber.notify)();
174                woke = true;
175            }
176        }
177        woke
178    })
179}
180
181/// Clears the measurement and every subscription. Used between tests.
182#[doc(hidden)]
183pub fn reset_window_size() {
184    CURRENT.with(|current| current.set(WindowSize::default()));
185    SUBSCRIBERS.with_borrow_mut(Vec::clear);
186    NODES.with_borrow_mut(Vec::clear);
187    NODE_SUBSCRIBERS.with_borrow_mut(Vec::clear);
188}
189
190/// A component's registration, dropped with the hook state when the component unmounts.
191struct WindowSizeSubscription {
192    id: u64,
193}
194
195impl WindowSizeSubscription {
196    fn new(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
197        let id = NEXT_SUBSCRIBER_ID.with(|next| {
198            let id = next.get();
199            next.set(id + 1);
200            id
201        });
202        SUBSCRIBERS.with_borrow_mut(|subscribers| subscribers.push(Subscriber { id, notify }));
203        Self { id }
204    }
205}
206
207impl Drop for WindowSizeSubscription {
208    fn drop(&mut self) {
209        SUBSCRIBERS.with_borrow_mut(|subscribers| {
210            subscribers.retain(|subscriber| subscriber.id != self.id);
211        });
212    }
213}
214
215/// Reads the window's size class inside a component, and re-renders it when the class
216/// changes.
217///
218/// ```ignore
219/// let window = use_window_size();
220/// rsx! {
221///     if window.is_expanded() {
222///         Row { Sidebar {} Content {} }
223///     } else {
224///         Content {}
225///     }
226/// }
227/// ```
228///
229/// Only components that call this are woken, and only when the class actually changes.
230/// Resizing inside one class re-renders nothing.
231/// A node whose measured size this component follows.
232///
233/// The token is the name the screen gives the node, because a node id belongs to the
234/// Renderer and never crosses back as something the Host chose. Attach it with
235/// `observe_size` and read the size here.
236///
237/// ```ignore
238/// let panel = use_node_size();
239/// rsx! {
240///     Card {
241///         observe_size: panel.token(),
242///         if panel.is_expanded() { Row { Left {} Right {} } } else { Left {} }
243///     }
244/// }
245/// ```
246///
247/// A component that never calls this costs nothing: the modifier is the only thing that
248/// makes the Renderer measure, and the modifier comes from here.
249#[derive(Clone, Copy, Debug, PartialEq)]
250pub struct NodeSize {
251    token: u32,
252    size: WindowSize,
253}
254
255impl NodeSize {
256    /// The value to hand to `observe_size`.
257    pub const fn token(&self) -> i64 {
258        self.token as i64
259    }
260
261    /// Phone-shaped: narrower than 600dp.
262    pub fn is_compact(&self) -> bool {
263        self.size.is_compact()
264    }
265
266    /// Tablet-shaped.
267    pub fn is_medium(&self) -> bool {
268        self.size.is_medium()
269    }
270
271    /// Desktop-shaped.
272    pub fn is_expanded(&self) -> bool {
273        self.size.is_expanded()
274    }
275
276    /// The measurement itself, zero until the Renderer has reported one.
277    pub const fn measured(&self) -> WindowSize {
278        self.size
279    }
280}
281
282thread_local! {
283    static NEXT_TOKEN: Cell<u32> = const { Cell::new(1) };
284}
285
286/// Follows one node's size, and re-renders this component when its class changes.
287pub fn use_node_size() -> NodeSize {
288    let token = dioxus_core::use_hook(|| {
289        let token = NEXT_TOKEN.with(|next| {
290            let token = next.get();
291            next.set(token + 1);
292            token
293        });
294        Rc::new(NodeSizeSubscription::new(
295            token,
296            dioxus_core::schedule_update(),
297        ))
298    });
299    NodeSize {
300        token: token.token,
301        size: node_size(token.token),
302    }
303}
304
305/// A component's registration for one node, dropped with its hook state.
306struct NodeSizeSubscription {
307    token: u32,
308    id: u64,
309}
310
311impl NodeSizeSubscription {
312    fn new(token: u32, notify: Arc<dyn Fn() + Send + Sync>) -> Self {
313        let id = NEXT_SUBSCRIBER_ID.with(|next| {
314            let id = next.get();
315            next.set(id + 1);
316            id
317        });
318        NODE_SUBSCRIBERS.with_borrow_mut(|subscribers| {
319            subscribers.push((token, Subscriber { id, notify }));
320        });
321        Self { token, id }
322    }
323}
324
325impl Drop for NodeSizeSubscription {
326    fn drop(&mut self) {
327        // The node going out of the tree takes the subscription with it, which is the
328        // third thing this requirement asks for.
329        NODE_SUBSCRIBERS.with_borrow_mut(|subscribers| {
330            subscribers.retain(|(_, subscriber)| subscriber.id != self.id);
331        });
332        NODES.with_borrow_mut(|nodes| nodes.retain(|(token, _)| *token != self.token));
333    }
334}
335
336pub fn use_window_size() -> WindowSize {
337    // Rc, because hook state has to be `Clone` and the registration must not be
338    // duplicated: dropping the last handle with the component's hook state is what
339    // removes the subscription.
340    dioxus_core::use_hook(|| Rc::new(WindowSizeSubscription::new(dioxus_core::schedule_update())));
341    window_size()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::boundary::Host;
348    use crate::prelude::*;
349    use crate::protocol::{HostEvent, Mutation, PropertyValue, decode_batch};
350    use crate::schema::{EventPayload, PropertyKind};
351    use std::sync::atomic::{AtomicUsize, Ordering};
352
353    static RESPONSIVE_RENDERS: AtomicUsize = AtomicUsize::new(0);
354    static SIBLING_RENDERS: AtomicUsize = AtomicUsize::new(0);
355
356    #[component]
357    fn Responsive() -> Element {
358        RESPONSIVE_RENDERS.fetch_add(1, Ordering::SeqCst);
359        let window = use_window_size();
360        let label = if window.is_expanded() {
361            "sidebar"
362        } else if window.is_medium() {
363            "two columns"
364        } else {
365            "one column"
366        };
367        rsx! { Text { text: label } }
368    }
369
370    #[component]
371    fn ShortOrTall() -> Element {
372        let window = use_window_size();
373        let label = if window.is_tall() {
374            "tall"
375        } else if window.is_medium_height() {
376            "ordinary"
377        } else {
378            "short"
379        };
380        rsx! { Text { text: label } }
381    }
382
383    fn height_app() -> Element {
384        rsx! { Column { ShortOrTall {} } }
385    }
386
387    #[component]
388    fn Sibling() -> Element {
389        SIBLING_RENDERS.fetch_add(1, Ordering::SeqCst);
390        rsx! { Text { text: "fixed" } }
391    }
392
393    fn responsive_app() -> Element {
394        rsx! {
395            Column {
396                Responsive {}
397                Sibling {}
398            }
399        }
400    }
401
402    fn resize(host: &mut Host, width_dp: f32) -> Vec<String> {
403        resize_to(host, width_dp, 800.0)
404    }
405
406    fn resize_to(host: &mut Host, width_dp: f32, height_dp: f32) -> Vec<String> {
407        let event = HostEvent {
408            node_id: 0,
409            handler_id: 0,
410            payload: EventPayload::WindowSizeChanged {
411                width_dp,
412                height_dp,
413                class: WindowSizeClass::from_width_dp(width_dp),
414                height_class: WindowHeightClass::from_height_dp(height_dp),
415            },
416        };
417        let (batch, _) = host.dispatch(event).unwrap();
418        decode_batch(batch)
419            .unwrap()
420            .iter()
421            .filter_map(|mutation| match mutation {
422                Mutation::SetProp {
423                    property: PropertyKind::Text,
424                    value: PropertyValue::String(value),
425                    ..
426                } => Some((*value).to_owned()),
427                _ => None,
428            })
429            .collect()
430    }
431
432    #[test]
433    fn fr20_crossing_a_boundary_rerenders_the_hook_and_resizing_within_a_class_does_not() {
434        reset_window_size();
435        RESPONSIVE_RENDERS.store(0, Ordering::SeqCst);
436        SIBLING_RENDERS.store(0, Ordering::SeqCst);
437        let mut host = Host::new(responsive_app);
438        host.rebuild().unwrap();
439        assert_eq!(RESPONSIVE_RENDERS.load(Ordering::SeqCst), 1);
440        assert_eq!(SIBLING_RENDERS.load(Ordering::SeqCst), 1);
441
442        // Crossing 600dp: the hook's component re-renders once, its sibling not at all.
443        assert_eq!(resize(&mut host, 700.0), vec!["two columns".to_owned()]);
444        assert_eq!(RESPONSIVE_RENDERS.load(Ordering::SeqCst), 2);
445        assert_eq!(SIBLING_RENDERS.load(Ordering::SeqCst), 1);
446
447        // A report that does not change the class changes nothing. The Renderer does not
448        // send one, and a Host that receives one anyway must not run the VirtualDom for it.
449        assert!(resize(&mut host, 700.0).is_empty());
450        assert_eq!(RESPONSIVE_RENDERS.load(Ordering::SeqCst), 2);
451
452        assert_eq!(resize(&mut host, 900.0), vec!["sidebar".to_owned()]);
453        assert_eq!(RESPONSIVE_RENDERS.load(Ordering::SeqCst), 3);
454        assert_eq!(SIBLING_RENDERS.load(Ordering::SeqCst), 1);
455        reset_window_size();
456    }
457
458    #[test]
459    fn fr20_crossing_a_height_boundary_rerenders_and_resizing_within_one_does_not() {
460        reset_window_size();
461        let mut host = Host::new(height_app);
462        host.rebuild().unwrap();
463
464        // 300dp tall is the class the Host already holds, so growing to 479dp inside it
465        // changes nothing.
466        assert!(resize_to(&mut host, 400.0, 479.0).is_empty());
467
468        // 480dp crosses into the ordinary height, and 900dp into the tall one. One
469        // mutation each, and nothing for the step in between.
470        assert_eq!(
471            resize_to(&mut host, 400.0, 480.0),
472            vec!["ordinary".to_owned()]
473        );
474        assert!(resize_to(&mut host, 400.0, 899.0).is_empty());
475        assert_eq!(resize_to(&mut host, 400.0, 900.0), vec!["tall".to_owned()]);
476        reset_window_size();
477    }
478
479    #[test]
480    fn fr20_window_size_starts_compact_before_the_renderer_measures_anything() {
481        reset_window_size();
482        assert_eq!(window_size(), WindowSize::default());
483        assert!(window_size().is_compact());
484    }
485
486    #[test]
487    fn fr20_publishing_the_same_size_twice_wakes_nobody() {
488        reset_window_size();
489        let size = WindowSize::new(
490            700.0,
491            800.0,
492            WindowSizeClass::Medium,
493            WindowHeightClass::Medium,
494        );
495        assert!(publish(size));
496        assert!(!publish(size));
497        reset_window_size();
498    }
499}