Skip to main content

repose_ui/
subcompose.rs

1//! SubcomposeLayout and BoxWithConstraints.
2//!
3//! These layouts compose their children during the *reconcile* pass using the
4//! current available size, so the inner content can adapt to the parent's
5//! constraints.
6
7use std::hash::{DefaultHasher, Hash, Hasher};
8use std::sync::Arc;
9
10use repose_core::{BoxWithConstraintsScope, Modifier, SubcomposeScope, View, ViewKind};
11
12/// Hash any `Hash` value into a `u64` suitable for use as a
13/// [`Modifier::key`](repose_core::Modifier::key).
14///
15/// This is what the `*_with_key` helpers use internally. It is exposed for
16/// callers who want to set the key on a `Modifier` directly.
17pub fn subcompose_hash_key<K: Hash>(key: &K) -> u64 {
18    let mut h = DefaultHasher::new();
19    key.hash(&mut h);
20    h.finish()
21}
22
23/// A layout whose `content` closure is invoked with the current available
24/// size (in dp) and returns one or more `(slot_id, view)` pairs.
25///
26/// The single-slot form takes a closure that returns a single `View`; that
27/// view is implicitly assigned slot id `0`. The multi-slot form takes a
28/// closure returning a `Vec<(u64, View)>` and is exposed by
29/// [`subcompose_layout_with_slots`] for callers that need multiple slots.
30///
31/// `content` runs during reconcile. The first frame the closure is called
32/// and its result is cached; subsequent frames reuse the cached result as
33/// long as the available scope (and the `SubcomposeLayout`'s modifier) are
34/// unchanged.
35///
36/// If `content` captures state (such as a `Signal`) whose changes should
37/// re-trigger the closure, use [`subcompose_with_key`] (or
38/// [`box_with_constraints_with_key`]) so the cache is invalidated when that
39/// state changes.
40pub fn SubcomposeLayout<F>(modifier: Modifier, content: F) -> View
41where
42    F: Fn(SubcomposeScope) -> View + 'static,
43{
44    let wrapped: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>> =
45        Arc::new(move |scope| vec![(0, content(*scope))]);
46    View {
47        id: 0,
48        kind: ViewKind::SubcomposeLayout { content: wrapped },
49        modifier,
50        children: Vec::new(),
51        scope_key: None,
52        semantics: None,
53    }
54}
55
56/// Multi-slot variant of [`SubcomposeLayout`]. The `content` closure receives
57/// the current scope and returns a list of `(slot_id, view)` pairs. Slot ids
58/// are stable across frames: removing or reordering slots preserves the
59/// underlying tree nodes.
60pub fn subcompose_layout_with_slots<F>(modifier: Modifier, content: F) -> View
61where
62    F: Fn(SubcomposeScope) -> Vec<(u64, View)> + 'static,
63{
64    let wrapped: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>> =
65        Arc::new(move |scope| content(*scope));
66    View {
67        id: 0,
68        kind: ViewKind::SubcomposeLayout { content: wrapped },
69        modifier,
70        children: Vec::new(),
71        scope_key: None,
72        semantics: None,
73    }
74}
75
76/// A [`SubcomposeLayout`] specialized for the "show different content based on
77/// the available width/height" use case.
78///
79/// The supplied `content` receives a [`BoxWithConstraintsScope`] containing the
80/// current constraints (in dp) and returns the `View` to render. The resulting
81/// view fills the available space.
82pub fn BoxWithConstraints<F>(modifier: Modifier, content: F) -> View
83where
84    F: Fn(BoxWithConstraintsScope) -> View + 'static,
85{
86    SubcomposeLayout(modifier, move |scope| {
87        content(BoxWithConstraintsScope {
88            min_width: scope.min_width,
89            max_width: scope.max_width,
90            min_height: scope.min_height,
91            max_height: scope.max_height,
92        })
93    })
94}
95
96/// Build a [`SubcomposeLayout`] that re-invokes its `content` closure whenever
97/// the hashed value of `key` changes.
98///
99/// Use this when the closure captures state that should re-trigger
100/// subcomposition. Typical pattern: read the signal *outside* the closure and
101/// pass the value here so the cache key changes when the signal changes.
102///
103/// ```ignore
104/// let count = signal.get();
105/// subcompose_with_key(count, modifier, move |scope| {
106///     let count = signal.get();  // inner read observes the same value
107///     Text(format!("count = {count}"))
108/// });
109/// ```
110pub fn subcompose_with_key<K, F>(key: K, modifier: Modifier, content: F) -> View
111where
112    K: Hash,
113    F: Fn(SubcomposeScope) -> View + 'static,
114{
115    let hashed = subcompose_hash_key(&key);
116    SubcomposeLayout(modifier.key(hashed), content)
117}
118
119/// Keyed variant of [`BoxWithConstraints`]. Re-invokes `content` whenever the
120/// hashed value of `key` changes.
121pub fn box_with_constraints_with_key<K, F>(key: K, modifier: Modifier, content: F) -> View
122where
123    K: Hash,
124    F: Fn(BoxWithConstraintsScope) -> View + 'static,
125{
126    subcompose_with_key(key, modifier, move |scope| {
127        content(BoxWithConstraintsScope {
128            min_width: scope.min_width,
129            max_width: scope.max_width,
130            min_height: scope.min_height,
131            max_height: scope.max_height,
132        })
133    })
134}
135
136/// Multi-slot keyed variant of [`subcompose_layout_with_slots`]. The `key`'s
137/// hashed value is attached to the resulting `SubcomposeLayout` so the cache
138/// is invalidated whenever the key changes.
139pub fn subcompose_with_key_slots<K, F>(key: K, modifier: Modifier, content: F) -> View
140where
141    K: Hash,
142    F: Fn(SubcomposeScope) -> Vec<(u64, View)> + 'static,
143{
144    let hashed = subcompose_hash_key(&key);
145    subcompose_layout_with_slots(modifier.key(hashed), content)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::layout::LayoutEngine;
152    use crate::{Column, Interactions, ViewExt};
153    use std::collections::HashMap;
154    use std::sync::Arc;
155    use std::sync::atomic::{AtomicUsize, Ordering};
156
157    fn text_view(text: &str) -> View {
158        use repose_core::{
159            Color, FontStyle, FontWeight, TextAlign, TextDecoration, TextOverflow, ViewKind,
160        };
161        View {
162            id: 0,
163            kind: ViewKind::Text {
164                text: text.to_string(),
165                color: Color::WHITE,
166                font_size: 14.0,
167                soft_wrap: true,
168                max_lines: None,
169                overflow: TextOverflow::Visible,
170                font_family: None,
171                annotations: None,
172                text_align: TextAlign::Unspecified,
173                font_weight: FontWeight::NORMAL,
174                font_style: FontStyle::Normal,
175                text_decoration: TextDecoration::default(),
176                letter_spacing: 0.0,
177                line_height: 0.0,
178                url: None,
179                font_variation_settings: None,
180            },
181            modifier: Modifier::default(),
182            children: vec![],
183            scope_key: None,
184            semantics: None,
185        }
186    }
187
188    fn make_root(view: View) -> View {
189        Column(Modifier::new()).child(view)
190    }
191
192    #[test]
193    fn subcompose_hash_key_is_deterministic_and_distinguishes_values() {
194        assert_eq!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"hello"));
195        assert_ne!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"world"));
196        assert_eq!(
197            subcompose_hash_key(&(1u32, 2u32)),
198            subcompose_hash_key(&(1u32, 2u32))
199        );
200        assert_ne!(
201            subcompose_hash_key(&(1u32, 2u32)),
202            subcompose_hash_key(&(1u32, 3u32))
203        );
204    }
205
206    #[test]
207    fn subcompose_with_key_runs_closure_once_until_key_changes() {
208        let calls = Arc::new(AtomicUsize::new(0));
209        let calls_c = calls.clone();
210
211        // Both roots share the same Arc<AtomicUsize> so the second closure's
212        // increments are visible to the assertion below.
213        let sub = subcompose_with_key(1u64, Modifier::new(), move |_scope| {
214            calls_c.fetch_add(1, Ordering::SeqCst);
215            text_view("k=1")
216        });
217        let root_v1 = make_root(sub);
218
219        let calls2 = calls.clone();
220        let sub = subcompose_with_key(2u64, Modifier::new(), move |_scope| {
221            calls2.fetch_add(1, Ordering::SeqCst);
222            text_view("k=2")
223        });
224        let root_v2 = make_root(sub);
225
226        let mut engine = LayoutEngine::new();
227
228        // Frame with key=1: closure runs once.
229        let _ = engine.layout_frame(
230            &root_v1,
231            (400, 400),
232            &HashMap::new(),
233            &Interactions::default(),
234            None,
235        );
236        assert_eq!(calls.load(Ordering::SeqCst), 1);
237
238        // Frame 2 with same key=1: layout cache now available from frame 1,
239        // so the visible scope narrows from window-sized to child-sized.
240        let _ = engine.layout_frame(
241            &root_v1,
242            (400, 400),
243            &HashMap::new(),
244            &Interactions::default(),
245            None,
246        );
247        assert_eq!(calls.load(Ordering::SeqCst), 2);
248
249        // Frame 3: scope stable (same layout cache), cache hits.
250        let _ = engine.layout_frame(
251            &root_v1,
252            (400, 400),
253            &HashMap::new(),
254            &Interactions::default(),
255            None,
256        );
257        assert_eq!(calls.load(Ordering::SeqCst), 2);
258
259        // Now switch to key=2 (a different subcompose node) and verify the
260        // new closure runs.
261        let _ = engine.layout_frame(
262            &root_v2,
263            (400, 400),
264            &HashMap::new(),
265            &Interactions::default(),
266            None,
267        );
268        assert_eq!(calls.load(Ordering::SeqCst), 3);
269    }
270
271    #[test]
272    fn box_with_constraints_with_key_forwards_scope() {
273        use crate::Box as RBox;
274        let sub = box_with_constraints_with_key(42u64, Modifier::new(), |scope| {
275            assert!(scope.max_width > 0.0);
276            RBox(Modifier::new())
277        });
278        // Smoke check: builds a valid View with the SubcomposeLayout kind.
279        match sub.kind {
280            ViewKind::SubcomposeLayout { .. } => {}
281            _ => panic!("expected SubcomposeLayout"),
282        }
283    }
284
285    #[test]
286    fn subcompose_with_key_slots_runs_closure_once_until_key_changes() {
287        let calls = Arc::new(AtomicUsize::new(0));
288        let calls_c = calls.clone();
289
290        let sub = subcompose_with_key_slots(1u64, Modifier::new(), move |_scope| {
291            calls_c.fetch_add(1, Ordering::SeqCst);
292            vec![(0, text_view("k=1")), (1, text_view("k=1b"))]
293        });
294        let root_v1 = make_root(sub);
295
296        let calls2 = calls.clone();
297        let sub2 = subcompose_with_key_slots(2u64, Modifier::new(), move |_scope| {
298            calls2.fetch_add(1, Ordering::SeqCst);
299            vec![(0, text_view("k=2"))]
300        });
301        let root_v2 = make_root(sub2);
302
303        let mut engine = LayoutEngine::new();
304
305        let _ = engine.layout_frame(
306            &root_v1,
307            (400, 400),
308            &HashMap::new(),
309            &Interactions::default(),
310            None,
311        );
312        assert_eq!(calls.load(Ordering::SeqCst), 1);
313
314        // Frame 2: layout cache narrows scope, cache miss.
315        let _ = engine.layout_frame(
316            &root_v1,
317            (400, 400),
318            &HashMap::new(),
319            &Interactions::default(),
320            None,
321        );
322        assert_eq!(calls.load(Ordering::SeqCst), 2);
323
324        // Frame 3: scope stable, cache hits.
325        let _ = engine.layout_frame(
326            &root_v1,
327            (400, 400),
328            &HashMap::new(),
329            &Interactions::default(),
330            None,
331        );
332        assert_eq!(calls.load(Ordering::SeqCst), 2);
333
334        // New key: closure runs.
335        let _ = engine.layout_frame(
336            &root_v2,
337            (400, 400),
338            &HashMap::new(),
339            &Interactions::default(),
340            None,
341        );
342        assert_eq!(calls.load(Ordering::SeqCst), 3);
343    }
344}