Skip to main content

fret_ui_kit/declarative/
controllable_state.rs

1//! Controllable vs uncontrolled state helpers (Radix-aligned outcomes).
2//!
3//! Upstream reference:
4//! - `repo-ref/primitives/packages/react/use-controllable-state/src/use-controllable-state.tsx`
5//!
6//! Radix components often support both:
7//! - controlled state (`prop` provided by the caller), and
8//! - uncontrolled state (`defaultProp` stored internally).
9//!
10//! In Fret, "controlled" maps to "a caller-provided `Model<T>`", while "uncontrolled" maps to an
11//! internal `Model<T>` stored in element state and initialized once from `default_value`.
12
13use std::marker::PhantomData;
14
15use fret_runtime::Model;
16use fret_ui::{ElementContext, UiHost};
17
18#[derive(Debug, Clone)]
19pub struct ControllableModel<T> {
20    model: Model<T>,
21    controlled: bool,
22    _phantom: PhantomData<fn() -> T>,
23}
24
25impl<T> ControllableModel<T> {
26    pub fn model(&self) -> Model<T> {
27        self.model.clone()
28    }
29
30    pub fn is_controlled(&self) -> bool {
31        self.controlled
32    }
33}
34
35/// Returns a `Model<T>` that behaves like Radix `useControllableState`:
36/// - if `controlled` is provided, it is used directly
37/// - otherwise an internal model is created (once) using `default_value`
38///
39/// Notes:
40/// - This helper intentionally does not provide an `on_change` callback. In Fret, consumers can
41///   observe models via `ModelWatchExt` / `observe_model` and react to updates.
42#[track_caller]
43pub fn use_controllable_model<T: Clone + 'static, H: UiHost>(
44    cx: &mut ElementContext<'_, H>,
45    controlled: Option<Model<T>>,
46    default_value: impl FnOnce() -> T,
47) -> ControllableModel<T> {
48    if let Some(controlled) = controlled {
49        return ControllableModel {
50            model: controlled,
51            controlled: true,
52            _phantom: PhantomData,
53        };
54    }
55
56    struct UncontrolledModelState<T> {
57        model: Option<Model<T>>,
58    }
59    impl<T> Default for UncontrolledModelState<T> {
60        fn default() -> Self {
61            Self { model: None }
62        }
63    }
64
65    let slot = cx.slot_id();
66    let model = cx.state_for(slot, UncontrolledModelState::<T>::default, |st| {
67        st.model.clone()
68    });
69    let model = if let Some(model) = model {
70        model
71    } else {
72        let model = cx.app.models_mut().insert(default_value());
73        cx.state_for(slot, UncontrolledModelState::<T>::default, |st| {
74            st.model = Some(model.clone());
75        });
76        model
77    };
78
79    ControllableModel {
80        model,
81        controlled: false,
82        _phantom: PhantomData,
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    use std::cell::Cell;
91
92    use fret_app::App;
93    use fret_core::{
94        AppWindowId, PathCommand, PathConstraints, PathId, PathMetrics, PathService, PathStyle,
95        Point, Px, Rect, Size, SvgId, SvgService, TextBlobId, TextConstraints, TextInput,
96        TextMetrics, TextService,
97    };
98    use fret_runtime::{FrameId, TickId};
99    use fret_ui::UiTree;
100
101    fn bounds() -> Rect {
102        Rect::new(
103            Point::new(Px(0.0), Px(0.0)),
104            Size::new(Px(200.0), Px(120.0)),
105        )
106    }
107
108    fn bump_frame(app: &mut App) {
109        app.set_tick_id(TickId(app.tick_id().0.saturating_add(1)));
110        app.set_frame_id(FrameId(app.frame_id().0.saturating_add(1)));
111    }
112
113    #[derive(Default)]
114    struct FakeServices;
115
116    impl TextService for FakeServices {
117        fn prepare(
118            &mut self,
119            _input: &TextInput,
120            _constraints: TextConstraints,
121        ) -> (TextBlobId, TextMetrics) {
122            (
123                TextBlobId::default(),
124                TextMetrics {
125                    size: Size::new(Px(0.0), Px(0.0)),
126                    baseline: Px(0.0),
127                },
128            )
129        }
130
131        fn release(&mut self, _blob: TextBlobId) {}
132    }
133
134    impl PathService for FakeServices {
135        fn prepare(
136            &mut self,
137            _commands: &[PathCommand],
138            _style: PathStyle,
139            _constraints: PathConstraints,
140        ) -> (PathId, PathMetrics) {
141            (PathId::default(), PathMetrics::default())
142        }
143
144        fn release(&mut self, _path: PathId) {}
145    }
146
147    impl SvgService for FakeServices {
148        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
149            SvgId::default()
150        }
151
152        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
153            true
154        }
155    }
156
157    impl fret_core::MaterialService for FakeServices {
158        fn register_material(
159            &mut self,
160            _desc: fret_core::MaterialDescriptor,
161        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
162            Err(fret_core::MaterialRegistrationError::Unsupported)
163        }
164
165        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
166            true
167        }
168    }
169
170    #[test]
171    fn use_controllable_model_prefers_controlled_and_does_not_call_default() {
172        let window = AppWindowId::default();
173        let mut app = App::new();
174        let mut ui: UiTree<App> = UiTree::new();
175        ui.set_window(window);
176
177        let controlled = app.models_mut().insert(123u32);
178        let called = Cell::new(0);
179
180        fret_ui::elements::with_element_cx(&mut app, window, bounds(), "test", |cx| {
181            let out = use_controllable_model(cx, Some(controlled.clone()), || {
182                called.set(called.get() + 1);
183                7u32
184            });
185            assert!(out.is_controlled());
186            assert_eq!(out.model(), controlled);
187        });
188
189        assert_eq!(called.get(), 0);
190    }
191
192    #[track_caller]
193    fn bump_root_scoped_counter<H: UiHost>(cx: &mut ElementContext<'_, H>) -> u32 {
194        cx.root_state(u32::default, |value| {
195            *value = value.saturating_add(1);
196            *value
197        })
198    }
199
200    #[track_caller]
201    fn bump_callsite_scoped_counter<H: UiHost>(cx: &mut ElementContext<'_, H>) -> u32 {
202        cx.slot_state(u32::default, |value| {
203            *value = value.saturating_add(1);
204            *value
205        })
206    }
207
208    fn two_root_scoped_counters<H: UiHost>(cx: &mut ElementContext<'_, H>) -> (u32, u32) {
209        let a = bump_root_scoped_counter(cx);
210        let b = bump_root_scoped_counter(cx);
211        (a, b)
212    }
213
214    fn two_callsite_scoped_counters<H: UiHost>(cx: &mut ElementContext<'_, H>) -> (u32, u32) {
215        let a = bump_callsite_scoped_counter(cx);
216        let b = bump_callsite_scoped_counter(cx);
217        (a, b)
218    }
219
220    #[test]
221    fn root_state_is_root_scoped_shared_slot_per_type() {
222        let window = AppWindowId::default();
223        let mut app = App::new();
224
225        let first =
226            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "root-state", |cx| {
227                two_root_scoped_counters(cx)
228            });
229        let second =
230            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "root-state", |cx| {
231                two_root_scoped_counters(cx)
232            });
233
234        assert_eq!(first, (1, 2));
235        assert_eq!(second, (3, 4));
236    }
237
238    #[test]
239    fn slot_state_is_independent_per_callsite() {
240        let window = AppWindowId::default();
241        let mut app = App::new();
242
243        let first =
244            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "slot-state", |cx| {
245                two_callsite_scoped_counters(cx)
246            });
247        let second =
248            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "slot-state", |cx| {
249                two_callsite_scoped_counters(cx)
250            });
251
252        assert_eq!(first, (1, 1));
253        assert_eq!(second, (2, 2));
254    }
255
256    #[test]
257    fn use_controllable_model_creates_one_internal_model_and_reuses_it() {
258        let window = AppWindowId::default();
259        let mut app = App::new();
260        let mut ui: UiTree<App> = UiTree::new();
261        ui.set_window(window);
262        let b = bounds();
263
264        let called = Cell::new(0);
265        let model_id_out = Cell::new(None);
266        let mut services = FakeServices;
267
268        let render = |ui: &mut UiTree<App>, app: &mut App, services: &mut FakeServices| {
269            bump_frame(app);
270            let called = &called;
271            let model_id_out = &model_id_out;
272
273            let root = fret_ui::declarative::render_root(
274                ui,
275                app,
276                services,
277                window,
278                b,
279                "controllable-state-test",
280                |cx| {
281                    vec![cx.keyed("controllable", |cx| {
282                        let out = use_controllable_model(cx, None::<Model<u32>>, || {
283                            called.set(called.get() + 1);
284                            42u32
285                        });
286                        model_id_out.set(Some(out.model().id()));
287                        cx.spacer(Default::default())
288                    })]
289                },
290            );
291            ui.set_root(root);
292            ui.layout_all(app, services, b, 1.0);
293        };
294
295        render(&mut ui, &mut app, &mut services);
296        let first = model_id_out.get().expect("model id after first render");
297        assert_eq!(called.get(), 1);
298
299        render(&mut ui, &mut app, &mut services);
300        let second = model_id_out.get().expect("model id after second render");
301        assert_eq!(called.get(), 1);
302        assert_eq!(first, second);
303    }
304
305    #[test]
306    fn use_controllable_model_uncontrolled_multiple_instances_do_not_collide() {
307        let window = AppWindowId::default();
308        let mut app = App::new();
309        let mut ui: UiTree<App> = UiTree::new();
310        ui.set_window(window);
311        let b = bounds();
312
313        let called = Cell::new(0);
314        let model_a_id_out = Cell::new(None);
315        let model_b_id_out = Cell::new(None);
316        let mut services = FakeServices;
317
318        let render = |ui: &mut UiTree<App>, app: &mut App, services: &mut FakeServices| {
319            bump_frame(app);
320            let called = &called;
321            let model_a_id_out = &model_a_id_out;
322            let model_b_id_out = &model_b_id_out;
323
324            let root = fret_ui::declarative::render_root(
325                ui,
326                app,
327                services,
328                window,
329                b,
330                "controllable-state-multi-test",
331                |cx| {
332                    vec![cx.keyed("controllable", |cx| {
333                        let a = use_controllable_model(cx, None::<Model<u32>>, || {
334                            called.set(called.get() + 1);
335                            1u32
336                        });
337                        let b = use_controllable_model(cx, None::<Model<u32>>, || {
338                            called.set(called.get() + 1);
339                            2u32
340                        });
341                        model_a_id_out.set(Some(a.model().id()));
342                        model_b_id_out.set(Some(b.model().id()));
343                        cx.spacer(Default::default())
344                    })]
345                },
346            );
347            ui.set_root(root);
348            ui.layout_all(app, services, b, 1.0);
349        };
350
351        render(&mut ui, &mut app, &mut services);
352        let first_a = model_a_id_out.get().expect("model a id after first render");
353        let first_b = model_b_id_out.get().expect("model b id after first render");
354        assert_eq!(called.get(), 2);
355        assert_ne!(first_a, first_b);
356
357        render(&mut ui, &mut app, &mut services);
358        let second_a = model_a_id_out
359            .get()
360            .expect("model a id after second render");
361        let second_b = model_b_id_out
362            .get()
363            .expect("model b id after second render");
364        assert_eq!(called.get(), 2);
365        assert_eq!(first_a, second_a);
366        assert_eq!(first_b, second_b);
367    }
368}