Skip to main content

fret_ui_kit/declarative/
form.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use fret_runtime::{Model, ModelHost, ModelId, ModelStore};
5use fret_ui::action::UiActionHost;
6
7use crate::headless::form_state::{FormFieldId, FormState, FormValidateMode};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum FormRevalidateMode {
11    #[default]
12    Never,
13    OnChange,
14}
15
16#[derive(Debug, Clone)]
17pub struct FormRegistryOptions {
18    pub touch_on_change: bool,
19    pub revalidate_mode: FormRevalidateMode,
20}
21
22impl Default for FormRegistryOptions {
23    fn default() -> Self {
24        Self {
25            touch_on_change: false,
26            revalidate_mode: FormRevalidateMode::OnChange,
27        }
28    }
29}
30
31#[derive(Debug, Clone)]
32pub struct FieldEval {
33    pub dirty: bool,
34    pub error: Option<Arc<str>>,
35}
36
37type FieldEvalFn = Arc<dyn Fn(&ModelStore, bool) -> FieldEval + 'static>;
38
39#[derive(Clone)]
40struct RegisteredField {
41    id: FormFieldId,
42    eval: FieldEvalFn,
43}
44
45/// Narrow interop bridge for form registries that track app-owned values in `Model<T>`.
46///
47/// This intentionally stays specific to the form registry surface rather than widening into a
48/// crate-wide `IntoModel<T>` story.
49pub trait IntoFormValueModel<T> {
50    fn into_form_value_model(self) -> Model<T>;
51}
52
53impl<T> IntoFormValueModel<T> for Model<T> {
54    fn into_form_value_model(self) -> Model<T> {
55        self
56    }
57}
58
59impl<T> IntoFormValueModel<T> for &Model<T> {
60    fn into_form_value_model(self) -> Model<T> {
61        self.clone()
62    }
63}
64
65/// A lightweight, opt-in registry that connects app-owned `Model<T>` values to a `FormState`.
66///
67/// Notes:
68/// - This is intentionally app-owned state (store it in your window state/driver), not a model.
69/// - Validation remains value-driven: callers provide `validate(&T) -> Option<Arc<str>>`.
70/// - The registry only reads values from the `ModelStore`; it never owns field values.
71#[derive(Clone, Default)]
72pub struct FormRegistry {
73    options: FormRegistryOptions,
74    fields: Vec<RegisteredField>,
75    by_model_id: HashMap<ModelId, usize>,
76}
77
78impl std::fmt::Debug for FormRegistry {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("FormRegistry")
81            .field("options", &self.options)
82            .field("fields_len", &self.fields.len())
83            .finish()
84    }
85}
86
87impl FormRegistry {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn options(mut self, options: FormRegistryOptions) -> Self {
93        self.options = options;
94        self
95    }
96
97    pub fn field_ids(&self) -> impl Iterator<Item = &FormFieldId> {
98        self.fields.iter().map(|f| &f.id)
99    }
100
101    pub fn register_into_form_state<H: ModelHost>(
102        &self,
103        host: &mut H,
104        form_state: &Model<FormState>,
105    ) {
106        let fields: Vec<FormFieldId> = self.field_ids().cloned().collect();
107        let _ = host.models_mut().update(form_state, move |st| {
108            for id in fields.iter().cloned() {
109                st.register_field(id);
110            }
111        });
112    }
113
114    pub fn register_field<T>(
115        &mut self,
116        id: impl Into<FormFieldId>,
117        model: impl IntoFormValueModel<T>,
118        initial: T,
119        validate: impl Fn(&T) -> Option<Arc<str>> + 'static,
120    ) where
121        T: Clone + PartialEq + 'static,
122    {
123        let id: FormFieldId = id.into();
124        let model = model.into_form_value_model();
125        let model_id = model.id();
126        let eval: FieldEvalFn = Arc::new(move |store, force_validate| {
127            let current = store
128                .read(&model, |v| v.clone())
129                .unwrap_or_else(|_| initial.clone());
130            let dirty = current != initial;
131            let error = force_validate.then(|| validate(&current)).flatten();
132            FieldEval { dirty, error }
133        });
134
135        let idx = self.fields.len();
136        self.fields.push(RegisteredField { id, eval });
137        self.by_model_id.insert(model_id, idx);
138    }
139
140    pub fn handle_model_changes<H: ModelHost>(
141        &self,
142        host: &mut H,
143        form_state: &Model<FormState>,
144        changed: &[ModelId],
145    ) {
146        if self.fields.is_empty() || changed.is_empty() {
147            return;
148        }
149
150        let (validate_mode, submit_count, error_fields) = host
151            .models()
152            .read(form_state, |st| {
153                (
154                    st.validate_mode,
155                    st.submit_count,
156                    st.errors.keys().cloned().collect::<HashSet<_>>(),
157                )
158            })
159            .unwrap_or((FormValidateMode::default(), 0, HashSet::new()));
160
161        let store = host.models();
162        let mut updates: Vec<(FormFieldId, FieldEval)> = Vec::new();
163        for &id in changed {
164            let Some(&idx) = self.by_model_id.get(&id) else {
165                continue;
166            };
167            let field = &self.fields[idx];
168            let has_error = error_fields.contains(&field.id);
169            let should_validate = match validate_mode {
170                FormValidateMode::OnChange | FormValidateMode::All => true,
171                FormValidateMode::OnSubmit => {
172                    has_error
173                        || (submit_count > 0
174                            && matches!(self.options.revalidate_mode, FormRevalidateMode::OnChange))
175                }
176            };
177            let eval = (field.eval)(store, should_validate);
178            updates.push((Arc::clone(&field.id), eval));
179        }
180
181        if updates.is_empty() {
182            return;
183        }
184
185        let touch_on_change = self.options.touch_on_change;
186        let _ = host.models_mut().update(form_state, move |st| {
187            for (id, eval) in updates.iter() {
188                st.set_dirty(Arc::clone(id), eval.dirty);
189                if touch_on_change && eval.dirty {
190                    st.touch(Arc::clone(id));
191                }
192                st.set_error_opt(Arc::clone(id), eval.error.clone());
193            }
194        });
195    }
196
197    pub fn submit<H: ModelHost>(&self, host: &mut H, form_state: &Model<FormState>) -> bool {
198        let store = host.models();
199        let mut evals: Vec<(FormFieldId, FieldEval)> = self
200            .fields
201            .iter()
202            .map(|f| (Arc::clone(&f.id), (f.eval)(store, true)))
203            .collect();
204
205        let _ = host.models_mut().update(form_state, move |st| {
206            st.begin_submit();
207            st.touch_all_registered();
208            for (id, eval) in evals.drain(..) {
209                st.set_dirty(Arc::clone(&id), eval.dirty);
210                st.set_error_opt(id, eval.error);
211            }
212            st.end_submit();
213        });
214
215        host.models()
216            .read(form_state, |st| st.is_valid())
217            .unwrap_or(false)
218    }
219
220    /// Object-safe form submission helper for action hooks.
221    ///
222    /// `fret_ui::action` callbacks receive a `&mut dyn UiActionHost` (object-safe by design),
223    /// while `submit()` is generic over `ModelHost`. This helper bridges that gap without
224    /// exposing `FormRegistry` internals to call sites.
225    pub fn submit_action_host(
226        &self,
227        host: &mut dyn UiActionHost,
228        form_state: &Model<FormState>,
229    ) -> bool {
230        let store = host.models_mut();
231        let mut evals: Vec<(FormFieldId, FieldEval)> = self
232            .fields
233            .iter()
234            .map(|f| (Arc::clone(&f.id), (f.eval)(&*store, true)))
235            .collect();
236
237        let _ = store.update(form_state, move |st| {
238            st.begin_submit();
239            st.touch_all_registered();
240            for (id, eval) in evals.drain(..) {
241                st.set_dirty(Arc::clone(&id), eval.dirty);
242                st.set_error_opt(id, eval.error);
243            }
244            st.end_submit();
245        });
246
247        store.read(form_state, |st| st.is_valid()).unwrap_or(false)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    const SOURCE: &str = include_str!("form.rs");
254
255    fn normalize_ws(source: &str) -> String {
256        source.split_whitespace().collect()
257    }
258
259    #[test]
260    fn form_registry_register_field_keeps_a_narrow_model_bridge() {
261        let implementation = SOURCE.split("#[cfg(test)]").next().unwrap_or(SOURCE);
262        let normalized = normalize_ws(implementation);
263
264        assert!(
265            normalized.contains(
266                "pubtraitIntoFormValueModel<T>{fninto_form_value_model(self)->Model<T>;}"
267            ),
268            "form registry should keep a dedicated narrow bridge trait instead of a broad generic model conversion story"
269        );
270        assert!(
271            normalized.contains(
272                "pubfnregister_field<T>(&mutself,id:implInto<FormFieldId>,model:implIntoFormValueModel<T>,initial:T,validate:implFn(&T)->Option<Arc<str>>+'static,)whereT:Clone+PartialEq+'static,"
273            ),
274            "register_field should accept the dedicated form-value bridge"
275        );
276        assert!(
277            !normalized.contains(
278                "pubfnregister_field<T>(&mutself,id:implInto<FormFieldId>,model:Model<T>,"
279            ),
280            "register_field should not regress to a raw Model<T>-only signature"
281        );
282    }
283}