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        semantics: None,
52    }
53}
54
55/// Multi-slot variant of [`SubcomposeLayout`]. The `content` closure receives
56/// the current scope and returns a list of `(slot_id, view)` pairs. Slot ids
57/// are stable across frames: removing or reordering slots preserves the
58/// underlying tree nodes.
59pub fn subcompose_layout_with_slots<F>(modifier: Modifier, content: F) -> View
60where
61    F: Fn(SubcomposeScope) -> Vec<(u64, View)> + 'static,
62{
63    let wrapped: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>> =
64        Arc::new(move |scope| content(*scope));
65    View {
66        id: 0,
67        kind: ViewKind::SubcomposeLayout { content: wrapped },
68        modifier,
69        children: Vec::new(),
70        semantics: None,
71    }
72}
73
74/// A [`SubcomposeLayout`] specialized for the "show different content based on
75/// the available width/height" use case.
76///
77/// The supplied `content` receives a [`BoxWithConstraintsScope`] containing the
78/// current constraints (in dp) and returns the `View` to render. The resulting
79/// view fills the available space.
80pub fn BoxWithConstraints<F>(modifier: Modifier, content: F) -> View
81where
82    F: Fn(BoxWithConstraintsScope) -> View + 'static,
83{
84    SubcomposeLayout(modifier, move |scope| {
85        content(BoxWithConstraintsScope {
86            min_width: scope.min_width,
87            max_width: scope.max_width,
88            min_height: scope.min_height,
89            max_height: scope.max_height,
90        })
91    })
92}
93
94/// Build a [`SubcomposeLayout`] that re-invokes its `content` closure whenever
95/// the hashed value of `key` changes.
96///
97/// Use this when the closure captures state that should re-trigger
98/// subcomposition. Typical pattern: read the signal *outside* the closure and
99/// pass the value here so the cache key changes when the signal changes.
100///
101/// ```ignore
102/// let count = signal.get();
103/// subcompose_with_key(count, modifier, move |scope| {
104///     let count = signal.get();  // inner read observes the same value
105///     Text(format!("count = {count}"))
106/// });
107/// ```
108pub fn subcompose_with_key<K, F>(key: K, modifier: Modifier, content: F) -> View
109where
110    K: Hash,
111    F: Fn(SubcomposeScope) -> View + 'static,
112{
113    let hashed = subcompose_hash_key(&key);
114    SubcomposeLayout(modifier.key(hashed), content)
115}
116
117/// Keyed variant of [`BoxWithConstraints`]. Re-invokes `content` whenever the
118/// hashed value of `key` changes.
119pub fn box_with_constraints_with_key<K, F>(key: K, modifier: Modifier, content: F) -> View
120where
121    K: Hash,
122    F: Fn(BoxWithConstraintsScope) -> View + 'static,
123{
124    subcompose_with_key(key, modifier, move |scope| {
125        content(BoxWithConstraintsScope {
126            min_width: scope.min_width,
127            max_width: scope.max_width,
128            min_height: scope.min_height,
129            max_height: scope.max_height,
130        })
131    })
132}
133
134/// Multi-slot keyed variant of [`subcompose_layout_with_slots`]. The `key`'s
135/// hashed value is attached to the resulting `SubcomposeLayout` so the cache
136/// is invalidated whenever the key changes.
137pub fn subcompose_with_key_slots<K, F>(key: K, modifier: Modifier, content: F) -> View
138where
139    K: Hash,
140    F: Fn(SubcomposeScope) -> Vec<(u64, View)> + 'static,
141{
142    let hashed = subcompose_hash_key(&key);
143    subcompose_layout_with_slots(modifier.key(hashed), content)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::layout::LayoutEngine;
150    use crate::{Column, Interactions, ViewExt};
151    use std::collections::HashMap;
152    use std::sync::Arc;
153    use std::sync::atomic::{AtomicUsize, Ordering};
154
155    fn text_view(text: &str) -> View {
156        use repose_core::{Color, TextOverflow, ViewKind};
157        View {
158            id: 0,
159            kind: ViewKind::Text {
160                text: text.to_string(),
161                color: Color::WHITE,
162                font_size: 14.0,
163                soft_wrap: true,
164                max_lines: None,
165                overflow: TextOverflow::Visible,
166                font_family: None,
167                annotations: None,
168            },
169            modifier: Modifier::default(),
170            children: vec![],
171            semantics: None,
172        }
173    }
174
175    fn make_root(view: View) -> View {
176        Column(Modifier::new()).child(view)
177    }
178
179    #[test]
180    fn subcompose_hash_key_is_deterministic_and_distinguishes_values() {
181        assert_eq!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"hello"));
182        assert_ne!(subcompose_hash_key(&"hello"), subcompose_hash_key(&"world"));
183        assert_eq!(
184            subcompose_hash_key(&(1u32, 2u32)),
185            subcompose_hash_key(&(1u32, 2u32))
186        );
187        assert_ne!(
188            subcompose_hash_key(&(1u32, 2u32)),
189            subcompose_hash_key(&(1u32, 3u32))
190        );
191    }
192
193    #[test]
194    fn subcompose_with_key_runs_closure_once_until_key_changes() {
195        let calls = Arc::new(AtomicUsize::new(0));
196        let calls_c = calls.clone();
197
198        // Both roots share the same Arc<AtomicUsize> so the second closure's
199        // increments are visible to the assertion below.
200        let sub = subcompose_with_key(1u64, Modifier::new(), move |_scope| {
201            calls_c.fetch_add(1, Ordering::SeqCst);
202            text_view("k=1")
203        });
204        let root_v1 = make_root(sub);
205
206        let calls2 = calls.clone();
207        let sub = subcompose_with_key(2u64, Modifier::new(), move |_scope| {
208            calls2.fetch_add(1, Ordering::SeqCst);
209            text_view("k=2")
210        });
211        let root_v2 = make_root(sub);
212
213        let mut engine = LayoutEngine::new();
214
215        // Frame with key=1: closure runs once.
216        let _ = engine.layout_frame(
217            &root_v1,
218            (400, 400),
219            &HashMap::new(),
220            &Interactions::default(),
221            None,
222        );
223        assert_eq!(calls.load(Ordering::SeqCst), 1);
224
225        // Repeat the same key=1 frame: closure stays cached.
226        let _ = engine.layout_frame(
227            &root_v1,
228            (400, 400),
229            &HashMap::new(),
230            &Interactions::default(),
231            None,
232        );
233        assert_eq!(calls.load(Ordering::SeqCst), 1);
234
235        // Now switch to key=2 (a different subcompose node) and verify the
236        // new closure runs.
237        let _ = engine.layout_frame(
238            &root_v2,
239            (400, 400),
240            &HashMap::new(),
241            &Interactions::default(),
242            None,
243        );
244        assert_eq!(calls.load(Ordering::SeqCst), 2);
245    }
246
247    #[test]
248    fn box_with_constraints_with_key_forwards_scope() {
249        use crate::Box as RBox;
250        let sub = box_with_constraints_with_key(42u64, Modifier::new(), |scope| {
251            assert!(scope.max_width > 0.0);
252            RBox(Modifier::new())
253        });
254        // Smoke check: builds a valid View with the SubcomposeLayout kind.
255        match sub.kind {
256            ViewKind::SubcomposeLayout { .. } => {}
257            _ => panic!("expected SubcomposeLayout"),
258        }
259    }
260
261    #[test]
262    fn subcompose_with_key_slots_runs_closure_once_until_key_changes() {
263        let calls = Arc::new(AtomicUsize::new(0));
264        let calls_c = calls.clone();
265
266        let sub = subcompose_with_key_slots(1u64, Modifier::new(), move |_scope| {
267            calls_c.fetch_add(1, Ordering::SeqCst);
268            vec![(0, text_view("k=1")), (1, text_view("k=1b"))]
269        });
270        let root_v1 = make_root(sub);
271
272        let calls2 = calls.clone();
273        let sub2 = subcompose_with_key_slots(2u64, Modifier::new(), move |_scope| {
274            calls2.fetch_add(1, Ordering::SeqCst);
275            vec![(0, text_view("k=2"))]
276        });
277        let root_v2 = make_root(sub2);
278
279        let mut engine = LayoutEngine::new();
280
281        let _ = engine.layout_frame(
282            &root_v1,
283            (400, 400),
284            &HashMap::new(),
285            &Interactions::default(),
286            None,
287        );
288        assert_eq!(calls.load(Ordering::SeqCst), 1);
289
290        // Same key: cache hit.
291        let _ = engine.layout_frame(
292            &root_v1,
293            (400, 400),
294            &HashMap::new(),
295            &Interactions::default(),
296            None,
297        );
298        assert_eq!(calls.load(Ordering::SeqCst), 1);
299
300        // New key: closure runs.
301        let _ = engine.layout_frame(
302            &root_v2,
303            (400, 400),
304            &HashMap::new(),
305            &Interactions::default(),
306            None,
307        );
308        assert_eq!(calls.load(Ordering::SeqCst), 2);
309    }
310}