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, FontStyle, FontWeight, TextAlign, TextDecoration, TextOverflow, ViewKind,
164        };
165        View {
166            id: 0,
167            kind: ViewKind::Text {
168                text: text.to_string(),
169                color: Color::WHITE,
170                font_size: 14.0,
171                soft_wrap: true,
172                max_lines: None,
173                overflow: TextOverflow::Visible,
174                font_family: None,
175                annotations: None,
176                text_align: TextAlign::Unspecified,
177                font_weight: FontWeight::NORMAL,
178                font_style: FontStyle::Normal,
179                text_decoration: TextDecoration::default(),
180                letter_spacing: 0.0,
181                line_height: 0.0,
182                url: None,
183                font_variation_settings: None,
184            },
185            modifier: Modifier::default(),
186            children: vec![],
187            scope_key: None,
188            semantics: None,
189        }
190    }
191
192    fn make_root(view: View) -> View {
193        Column(Modifier::new()).child(view)
194    }
195
196    #[test]
197    fn subcompose_hash_key_is_deterministic_and_distinguishes_values() {
198        assert_eq!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"hello"));
199        assert_ne!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"world"));
200        assert_eq!(
201            subcompose_hash_key(&(1u32, 2u32)),
202            subcompose_hash_key(&(1u32, 2u32))
203        );
204        assert_ne!(
205            subcompose_hash_key(&(1u32, 2u32)),
206            subcompose_hash_key(&(1u32, 3u32))
207        );
208    }
209
210    #[test]
211    fn subcompose_with_key_runs_closure_once_until_key_changes() {
212        let calls = Arc::new(AtomicUsize::new(0));
213        let calls_c = calls.clone();
214
215        // Both roots share the same Arc<AtomicUsize> so the second closure's
216        // increments are visible to the assertion below.
217        let sub = subcompose_with_key(1u64, Modifier::new(), move |_scope| {
218            calls_c.fetch_add(1, Ordering::SeqCst);
219            text_view("k=1")
220        });
221        let root_v1 = make_root(sub);
222
223        let calls2 = calls.clone();
224        let sub = subcompose_with_key(2u64, Modifier::new(), move |_scope| {
225            calls2.fetch_add(1, Ordering::SeqCst);
226            text_view("k=2")
227        });
228        let root_v2 = make_root(sub);
229
230        let mut engine = LayoutEngine::new();
231
232        // Frame with key=1: closure runs once.
233        let _ = engine.layout_frame(
234            &root_v1,
235            (400, 400),
236            &HashMap::new(),
237            &Interactions::default(),
238            None,
239        );
240        assert_eq!(calls.load(Ordering::SeqCst), 1);
241
242        // Frame 2 with same key=1: layout cache now available from frame 1,
243        // so the visible scope narrows from window-sized to child-sized.
244        let _ = engine.layout_frame(
245            &root_v1,
246            (400, 400),
247            &HashMap::new(),
248            &Interactions::default(),
249            None,
250        );
251        assert_eq!(calls.load(Ordering::SeqCst), 2);
252
253        // Frame 3: scope stable (same layout cache), cache hits.
254        let _ = engine.layout_frame(
255            &root_v1,
256            (400, 400),
257            &HashMap::new(),
258            &Interactions::default(),
259            None,
260        );
261        assert_eq!(calls.load(Ordering::SeqCst), 2);
262
263        // Now switch to key=2 (a different subcompose node) and verify the
264        // new closure runs.
265        let _ = engine.layout_frame(
266            &root_v2,
267            (400, 400),
268            &HashMap::new(),
269            &Interactions::default(),
270            None,
271        );
272        assert_eq!(calls.load(Ordering::SeqCst), 3);
273    }
274
275    #[test]
276    fn box_with_constraints_with_key_forwards_scope() {
277        use crate::Box as RBox;
278        let sub = box_with_constraints_with_key(42u64, Modifier::new(), |scope| {
279            assert!(scope.max_width > 0.0);
280            RBox(Modifier::new())
281        });
282        // Smoke check: builds a valid View with the SubcomposeLayout kind.
283        match sub.kind {
284            ViewKind::SubcomposeLayout { .. } => {}
285            _ => panic!("expected SubcomposeLayout"),
286        }
287    }
288
289    #[test]
290    fn subcompose_with_key_slots_runs_closure_once_until_key_changes() {
291        let calls = Arc::new(AtomicUsize::new(0));
292        let calls_c = calls.clone();
293
294        let sub = subcompose_with_key_slots(1u64, Modifier::new(), move |_scope| {
295            calls_c.fetch_add(1, Ordering::SeqCst);
296            vec![(0, text_view("k=1")), (1, text_view("k=1b"))]
297        });
298        let root_v1 = make_root(sub);
299
300        let calls2 = calls.clone();
301        let sub2 = subcompose_with_key_slots(2u64, Modifier::new(), move |_scope| {
302            calls2.fetch_add(1, Ordering::SeqCst);
303            vec![(0, text_view("k=2"))]
304        });
305        let root_v2 = make_root(sub2);
306
307        let mut engine = LayoutEngine::new();
308
309        let _ = engine.layout_frame(
310            &root_v1,
311            (400, 400),
312            &HashMap::new(),
313            &Interactions::default(),
314            None,
315        );
316        assert_eq!(calls.load(Ordering::SeqCst), 1);
317
318        // Frame 2: layout cache narrows scope, cache miss.
319        let _ = engine.layout_frame(
320            &root_v1,
321            (400, 400),
322            &HashMap::new(),
323            &Interactions::default(),
324            None,
325        );
326        assert_eq!(calls.load(Ordering::SeqCst), 2);
327
328        // Frame 3: scope stable, cache hits.
329        let _ = engine.layout_frame(
330            &root_v1,
331            (400, 400),
332            &HashMap::new(),
333            &Interactions::default(),
334            None,
335        );
336        assert_eq!(calls.load(Ordering::SeqCst), 2);
337
338        // New key: closure runs.
339        let _ = engine.layout_frame(
340            &root_v2,
341            (400, 400),
342            &HashMap::new(),
343            &Interactions::default(),
344            None,
345        );
346        assert_eq!(calls.load(Ordering::SeqCst), 3);
347    }
348}