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::atomic::{AtomicUsize, Ordering};
153    use std::sync::Arc;
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!(subcompose_hash_key(&(1u32, 2u32)), subcompose_hash_key(&(1u32, 2u32)));
184        assert_ne!(subcompose_hash_key(&(1u32, 2u32)), subcompose_hash_key(&(1u32, 3u32)));
185    }
186
187    #[test]
188    fn subcompose_with_key_runs_closure_once_until_key_changes() {
189        let calls = Arc::new(AtomicUsize::new(0));
190        let calls_c = calls.clone();
191
192        // Both roots share the same Arc<AtomicUsize> so the second closure's
193        // increments are visible to the assertion below.
194        let sub = subcompose_with_key(1u64, Modifier::new(), move |_scope| {
195            calls_c.fetch_add(1, Ordering::SeqCst);
196            text_view("k=1")
197        });
198        let root_v1 = make_root(sub);
199
200        let calls2 = calls.clone();
201        let sub = subcompose_with_key(2u64, Modifier::new(), move |_scope| {
202            calls2.fetch_add(1, Ordering::SeqCst);
203            text_view("k=2")
204        });
205        let root_v2 = make_root(sub);
206
207        let mut engine = LayoutEngine::new();
208
209        // Frame with key=1: closure runs once.
210        let _ = engine.layout_frame(
211            &root_v1,
212            (400, 400),
213            &HashMap::new(),
214            &Interactions::default(),
215            None,
216        );
217        assert_eq!(calls.load(Ordering::SeqCst), 1);
218
219        // Repeat the same key=1 frame: closure stays cached.
220        let _ = engine.layout_frame(
221            &root_v1,
222            (400, 400),
223            &HashMap::new(),
224            &Interactions::default(),
225            None,
226        );
227        assert_eq!(calls.load(Ordering::SeqCst), 1);
228
229        // Now switch to key=2 (a different subcompose node) and verify the
230        // new closure runs.
231        let _ = engine.layout_frame(
232            &root_v2,
233            (400, 400),
234            &HashMap::new(),
235            &Interactions::default(),
236            None,
237        );
238        assert_eq!(calls.load(Ordering::SeqCst), 2);
239    }
240
241    #[test]
242    fn box_with_constraints_with_key_forwards_scope() {
243        use crate::Box as RBox;
244        let sub = box_with_constraints_with_key(
245            42u64,
246            Modifier::new(),
247            |scope| {
248                assert!(scope.max_width > 0.0);
249                RBox(Modifier::new())
250            },
251        );
252        // Smoke check: builds a valid View with the SubcomposeLayout kind.
253        match sub.kind {
254            ViewKind::SubcomposeLayout { .. } => {}
255            _ => panic!("expected SubcomposeLayout"),
256        }
257    }
258
259    #[test]
260    fn subcompose_with_key_slots_runs_closure_once_until_key_changes() {
261        let calls = Arc::new(AtomicUsize::new(0));
262        let calls_c = calls.clone();
263
264        let sub = subcompose_with_key_slots(1u64, Modifier::new(), move |_scope| {
265            calls_c.fetch_add(1, Ordering::SeqCst);
266            vec![(0, text_view("k=1")), (1, text_view("k=1b"))]
267        });
268        let root_v1 = make_root(sub);
269
270        let calls2 = calls.clone();
271        let sub2 = subcompose_with_key_slots(2u64, Modifier::new(), move |_scope| {
272            calls2.fetch_add(1, Ordering::SeqCst);
273            vec![(0, text_view("k=2"))]
274        });
275        let root_v2 = make_root(sub2);
276
277        let mut engine = LayoutEngine::new();
278
279        let _ = engine.layout_frame(
280            &root_v1,
281            (400, 400),
282            &HashMap::new(),
283            &Interactions::default(),
284            None,
285        );
286        assert_eq!(calls.load(Ordering::SeqCst), 1);
287
288        // Same key: cache hit.
289        let _ = engine.layout_frame(
290            &root_v1,
291            (400, 400),
292            &HashMap::new(),
293            &Interactions::default(),
294            None,
295        );
296        assert_eq!(calls.load(Ordering::SeqCst), 1);
297
298        // New key: closure runs.
299        let _ = engine.layout_frame(
300            &root_v2,
301            (400, 400),
302            &HashMap::new(),
303            &Interactions::default(),
304            None,
305        );
306        assert_eq!(calls.load(Ordering::SeqCst), 2);
307    }
308}