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