Skip to main content

gpui_rhai/
state.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::rc::Rc;
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{SchemaDefinitionError, SchemaValidationError, UiValue, ValueSchema};
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11pub struct ComponentInstancePath(Vec<ComponentInstanceSegment>);
12
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
14struct ComponentInstanceSegment {
15    component: String,
16    key: String,
17}
18
19impl ComponentInstancePath {
20    #[must_use]
21    pub fn root(component: impl Into<String>, key: impl Into<String>) -> Self {
22        Self(vec![ComponentInstanceSegment {
23            component: component.into(),
24            key: key.into(),
25        }])
26    }
27
28    #[must_use]
29    pub fn child(&self, component: impl Into<String>, key: impl Into<String>) -> Self {
30        let mut segments = self.0.clone();
31        segments.push(ComponentInstanceSegment {
32            component: component.into(),
33            key: key.into(),
34        });
35        Self(segments)
36    }
37
38    #[must_use]
39    pub fn parent(&self) -> Option<Self> {
40        if self.0.len() <= 1 {
41            return None;
42        }
43        let mut segments = self.0.clone();
44        segments.pop();
45        Some(Self(segments))
46    }
47
48    #[must_use]
49    pub fn is_within(&self, ancestor: &Self) -> bool {
50        self.0.starts_with(&ancestor.0)
51    }
52
53    pub(crate) fn single_root_key(&self, component: &str) -> Option<&str> {
54        (self.0.len() == 1 && self.0[0].component == component).then_some(self.0[0].key.as_str())
55    }
56}
57
58impl fmt::Display for ComponentInstancePath {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        for segment in &self.0 {
61            write!(formatter, "/{}[{}]", segment.component, segment.key)?;
62        }
63        Ok(())
64    }
65}
66
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct StateField {
69    pub schema: ValueSchema,
70    pub default: UiValue,
71    #[serde(default)]
72    pub sensitive: bool,
73}
74
75impl StateField {
76    #[must_use]
77    pub fn new(schema: ValueSchema, default: UiValue) -> Self {
78        Self {
79            schema,
80            default,
81            sensitive: false,
82        }
83    }
84
85    #[must_use]
86    pub const fn sensitive(mut self, sensitive: bool) -> Self {
87        self.sensitive = sensitive;
88        self
89    }
90}
91
92#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
93pub struct ComponentStateSchema {
94    fields: BTreeMap<String, StateField>,
95}
96
97impl ComponentStateSchema {
98    /// Create a state schema after validating every declared default.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`StateError::InvalidDefault`] when a default does not satisfy
103    /// its field schema.
104    pub fn new(fields: BTreeMap<String, StateField>) -> Result<Self, StateError> {
105        for (name, field) in &fields {
106            field
107                .schema
108                .validate_definition()
109                .map_err(|source| StateError::InvalidSchema {
110                    field: name.clone(),
111                    source,
112                })?;
113            field
114                .schema
115                .validate_ui_value(&field.default)
116                .map_err(|source| StateError::InvalidDefault {
117                    field: name.clone(),
118                    source,
119                })?;
120        }
121        Ok(Self { fields })
122    }
123
124    #[must_use]
125    pub fn is_empty(&self) -> bool {
126        self.fields.is_empty()
127    }
128
129    #[must_use]
130    pub fn field(&self, name: &str) -> Option<&StateField> {
131        self.fields.get(name)
132    }
133
134    #[must_use]
135    pub fn fields(&self) -> &BTreeMap<String, StateField> {
136        &self.fields
137    }
138
139    fn defaults(&self) -> BTreeMap<String, UiValue> {
140        self.fields
141            .iter()
142            .map(|(name, field)| (name.clone(), field.default.clone()))
143            .collect()
144    }
145}
146
147#[derive(Clone, Debug, Default)]
148pub struct StateStore {
149    instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
150}
151
152#[derive(Clone, Debug)]
153struct ComponentState {
154    schema: Rc<ComponentStateSchema>,
155    values: Rc<BTreeMap<String, UiValue>>,
156}
157
158impl StateStore {
159    #[must_use]
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Start an isolated render-state transaction.
165    ///
166    /// Dropping the transaction leaves the committed store unchanged. Passing
167    /// it to [`StateStore::commit_render`] installs compatible state and removes
168    /// instances not reached during the successful render.
169    #[must_use]
170    pub fn begin_render(&self) -> RenderStateTransaction {
171        RenderStateTransaction {
172            instances: Rc::clone(&self.instances),
173            seen: BTreeSet::new(),
174            scope: None,
175        }
176    }
177
178    /// Start a transaction that reconciles only one component subtree.
179    /// State belonging to other window roots remains untouched on commit.
180    #[must_use]
181    pub fn begin_render_scope(&self, scope: ComponentInstancePath) -> RenderStateTransaction {
182        RenderStateTransaction {
183            instances: Rc::clone(&self.instances),
184            seen: BTreeSet::new(),
185            scope: Some(scope),
186        }
187    }
188
189    pub fn commit_render(&mut self, mut transaction: RenderStateTransaction) {
190        Rc::make_mut(&mut transaction.instances).retain(|path, _| {
191            transaction.seen.contains(path)
192                || transaction
193                    .scope
194                    .as_ref()
195                    .is_some_and(|scope| !path.is_within(scope))
196        });
197        self.instances = transaction.instances;
198    }
199
200    /// Remove one component subtree, including every descendant instance.
201    pub fn remove_scope(&mut self, scope: &ComponentInstancePath) {
202        Rc::make_mut(&mut self.instances).retain(|path, _| !path.is_within(scope));
203    }
204
205    /// Mount or reconcile one instance without cleaning any other path.
206    /// Used to make candidate component defaults readable during `view`.
207    ///
208    /// # Errors
209    ///
210    /// Returns invalid-schema/default errors.
211    pub fn mount_instance(
212        &mut self,
213        path: ComponentInstancePath,
214        schema: &ComponentStateSchema,
215    ) -> Result<StateReconcileReport, StateError> {
216        let report = reconcile_instance(Rc::make_mut(&mut self.instances), path, schema)?;
217        Ok(report)
218    }
219
220    #[must_use]
221    pub fn get(&self, path: &ComponentInstancePath, field: &str) -> Option<&UiValue> {
222        self.instances.get(path)?.values.get(field)
223    }
224
225    /// Update a declared field on a committed component instance.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`StateError::UnknownInstance`], [`StateError::UnknownField`], or
230    /// [`StateError::InvalidValue`] when the update violates committed state.
231    pub fn set(
232        &mut self,
233        path: &ComponentInstancePath,
234        field: &str,
235        value: UiValue,
236    ) -> Result<bool, StateError> {
237        let state = Rc::make_mut(&mut self.instances)
238            .get_mut(path)
239            .ok_or_else(|| StateError::UnknownInstance(path.clone()))?;
240        let state_field = state
241            .schema
242            .field(field)
243            .ok_or_else(|| StateError::UnknownField {
244                path: path.clone(),
245                field: field.to_owned(),
246            })?;
247        state_field
248            .schema
249            .validate_ui_value(&value)
250            .map_err(|source| StateError::InvalidValue {
251                path: path.clone(),
252                field: field.to_owned(),
253                source,
254            })?;
255        if state.values.get(field) == Some(&value) {
256            return Ok(false);
257        }
258        Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
259        Ok(true)
260    }
261
262    #[must_use]
263    pub fn instance_count(&self) -> usize {
264        self.instances.len()
265    }
266
267    pub(crate) fn contains_instance(&self, path: &ComponentInstancePath) -> bool {
268        self.instances.contains_key(path)
269    }
270
271    #[must_use]
272    pub fn paths(&self) -> BTreeSet<ComponentInstancePath> {
273        self.instances.keys().cloned().collect()
274    }
275
276    #[must_use]
277    pub fn is_sensitive(&self, path: &ComponentInstancePath, field: &str) -> bool {
278        self.instances
279            .get(path)
280            .and_then(|state| state.schema.field(field))
281            .is_some_and(|field| field.sensitive)
282    }
283
284    #[must_use]
285    pub fn inspect(&self) -> Vec<StateInstanceSnapshot> {
286        self.instances
287            .iter()
288            .map(|(path, state)| StateInstanceSnapshot {
289                path: path.clone(),
290                fields: state
291                    .values
292                    .iter()
293                    .map(|(name, value)| {
294                        let sensitive = state
295                            .schema
296                            .field(name)
297                            .is_some_and(|field| field.sensitive);
298                        (
299                            name.clone(),
300                            StateValueSnapshot {
301                                value: if sensitive {
302                                    UiValue::String("<sensitive>".to_owned())
303                                } else {
304                                    value.clone()
305                                },
306                                sensitive,
307                            },
308                        )
309                    })
310                    .collect(),
311            })
312            .collect()
313    }
314}
315
316#[derive(Clone, Debug, PartialEq)]
317pub struct StateInstanceSnapshot {
318    pub path: ComponentInstancePath,
319    pub fields: BTreeMap<String, StateValueSnapshot>,
320}
321
322#[derive(Clone, Debug, PartialEq)]
323pub struct StateValueSnapshot {
324    pub value: UiValue,
325    pub sensitive: bool,
326}
327
328#[derive(Clone, Debug)]
329pub struct RenderStateTransaction {
330    instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
331    seen: BTreeSet<ComponentInstancePath>,
332    scope: Option<ComponentInstancePath>,
333}
334
335impl RenderStateTransaction {
336    /// Retain a previously mounted instance without changing its schema.
337    #[must_use]
338    pub fn retain_existing(&mut self, path: &ComponentInstancePath) -> bool {
339        if self.instances.contains_key(path) {
340            self.seen.insert(path.clone());
341            true
342        } else {
343            false
344        }
345    }
346
347    /// Mount or reconcile an instance inside this prospective render.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`StateError::InvalidDefault`] if a programmatically constructed
352    /// schema contains an invalid default.
353    pub fn mount(
354        &mut self,
355        path: ComponentInstancePath,
356        schema: &ComponentStateSchema,
357    ) -> Result<StateReconcileReport, StateError> {
358        let report = reconcile_instance(Rc::make_mut(&mut self.instances), path.clone(), schema)?;
359        self.seen.insert(path);
360        Ok(report)
361    }
362}
363
364fn reconcile_instance(
365    instances: &mut BTreeMap<ComponentInstancePath, ComponentState>,
366    path: ComponentInstancePath,
367    schema: &ComponentStateSchema,
368) -> Result<StateReconcileReport, StateError> {
369    // Public serde construction can bypass `ComponentStateSchema::new`.
370    ComponentStateSchema::new(schema.fields.clone())?;
371
372    let next = match instances.get(&path) {
373        None => (
374            ComponentState {
375                schema: Rc::new(schema.clone()),
376                values: Rc::new(schema.defaults()),
377            },
378            StateReconcileReport {
379                created: true,
380                reset_fields: Vec::new(),
381            },
382        ),
383        Some(previous) => {
384            let mut reset_fields = Vec::new();
385            let values = schema
386                .fields
387                .iter()
388                .map(|(name, field)| {
389                    let value = previous
390                        .values
391                        .get(name)
392                        .filter(|value| field.schema.validate_ui_value(value).is_ok())
393                        .cloned()
394                        .unwrap_or_else(|| {
395                            if previous.values.contains_key(name) {
396                                reset_fields.push(name.clone());
397                            }
398                            field.default.clone()
399                        });
400                    (name.clone(), value)
401                })
402                .collect();
403            (
404                ComponentState {
405                    schema: Rc::new(schema.clone()),
406                    values: Rc::new(values),
407                },
408                StateReconcileReport {
409                    created: false,
410                    reset_fields,
411                },
412            )
413        }
414    };
415    instances.insert(path, next.0);
416    Ok(next.1)
417}
418
419#[derive(Clone, Debug, Default, Eq, PartialEq)]
420pub struct StateReconcileReport {
421    pub created: bool,
422    pub reset_fields: Vec<String>,
423}
424
425#[derive(Clone, Debug, Error, Eq, PartialEq)]
426pub enum StateError {
427    #[error("schema for state field `{field}` is invalid: {source}")]
428    InvalidSchema {
429        field: String,
430        source: SchemaDefinitionError,
431    },
432    #[error("default for state field `{field}` is invalid: {source}")]
433    InvalidDefault {
434        field: String,
435        source: SchemaValidationError,
436    },
437    #[error("component state instance `{0}` does not exist")]
438    UnknownInstance(ComponentInstancePath),
439    #[error("state field `{field}` is not declared for component `{path}`")]
440    UnknownField {
441        path: ComponentInstancePath,
442        field: String,
443    },
444    #[error("state value for `{path}.{field}` is invalid: {source}")]
445    InvalidValue {
446        path: ComponentInstancePath,
447        field: String,
448        source: SchemaValidationError,
449    },
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn schema(value_schema: ValueSchema, default: UiValue) -> ComponentStateSchema {
457        ComponentStateSchema::new(BTreeMap::from([(
458            "value".to_owned(),
459            StateField::new(value_schema, default),
460        )]))
461        .unwrap()
462    }
463
464    #[test]
465    fn failed_render_does_not_change_committed_state() {
466        let path = ComponentInstancePath::root("App", "root").child("Combobox", "country");
467        let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
468        let mut store = StateStore::new();
469
470        let mut initial = store.begin_render();
471        initial.mount(path.clone(), &bool_schema).unwrap();
472        store.commit_render(initial);
473        store.set(&path, "value", UiValue::Bool(true)).unwrap();
474
475        let mut failed = store.begin_render();
476        failed
477            .mount(
478                path.clone(),
479                &schema(
480                    ValueSchema::enumeration(["new"]),
481                    UiValue::String("new".to_owned()),
482                ),
483            )
484            .unwrap();
485        drop(failed);
486
487        assert_eq!(store.get(&path, "value"), Some(&UiValue::Bool(true)));
488    }
489
490    #[test]
491    fn successful_render_resets_only_incompatible_fields() {
492        let path = ComponentInstancePath::root("App", "root");
493        let mut store = StateStore::new();
494        let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
495        let mut initial = store.begin_render();
496        initial.mount(path.clone(), &bool_schema).unwrap();
497        store.commit_render(initial);
498        store.set(&path, "value", UiValue::Bool(true)).unwrap();
499
500        let string_schema = schema(ValueSchema::string(), UiValue::String("reset".to_owned()));
501        let mut reload = store.begin_render();
502        let report = reload.mount(path.clone(), &string_schema).unwrap();
503        store.commit_render(reload);
504
505        assert_eq!(report.reset_fields, vec!["value"]);
506        assert_eq!(
507            store.get(&path, "value"),
508            Some(&UiValue::String("reset".to_owned()))
509        );
510    }
511
512    #[test]
513    fn successful_render_cleans_unreachable_instances() {
514        let first = ComponentInstancePath::root("App", "root").child("Row", "first");
515        let second = ComponentInstancePath::root("App", "root").child("Row", "second");
516        let state_schema = schema(ValueSchema::integer(), UiValue::Integer(0));
517        let mut store = StateStore::new();
518        let mut initial = store.begin_render();
519        initial.mount(first.clone(), &state_schema).unwrap();
520        initial.mount(second, &state_schema).unwrap();
521        store.commit_render(initial);
522        assert_eq!(store.instance_count(), 2);
523
524        let mut next = store.begin_render();
525        next.mount(first, &state_schema).unwrap();
526        store.commit_render(next);
527        assert_eq!(store.instance_count(), 1);
528    }
529
530    #[test]
531    fn snapshots_share_instances_until_one_store_mutates() {
532        let path = ComponentInstancePath::root("Counter", "one");
533        let schema = schema(ValueSchema::integer(), UiValue::Integer(0));
534        let mut store = StateStore::new();
535        store.mount_instance(path.clone(), &schema).unwrap();
536        let snapshot = store.clone();
537        assert!(Rc::ptr_eq(&store.instances, &snapshot.instances));
538        store.set(&path, "value", UiValue::Integer(4)).unwrap();
539        assert!(!Rc::ptr_eq(&store.instances, &snapshot.instances));
540        assert_eq!(snapshot.get(&path, "value"), Some(&UiValue::Integer(0)));
541    }
542
543    #[test]
544    fn updates_are_schema_checked() {
545        let path = ComponentInstancePath::root("App", "root");
546        let state_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
547        let mut store = StateStore::new();
548        let mut render = store.begin_render();
549        render.mount(path.clone(), &state_schema).unwrap();
550        store.commit_render(render);
551
552        assert!(matches!(
553            store.set(&path, "value", UiValue::String("no".to_owned())),
554            Err(StateError::InvalidValue { .. })
555        ));
556    }
557
558    #[test]
559    fn scoped_commit_preserves_other_window_roots_and_remove_scope_cleans_one() {
560        let state_schema = schema(ValueSchema::string(), UiValue::String("ready".to_owned()));
561        let main = ComponentInstancePath::root("App", "main");
562        let settings = ComponentInstancePath::root("App", "settings");
563        let mut store = StateStore::new();
564        let mut initial = store.begin_render();
565        initial.mount(main.clone(), &state_schema).unwrap();
566        initial.mount(settings.clone(), &state_schema).unwrap();
567        store.commit_render(initial);
568
569        let mut main_render = store.begin_render_scope(main.clone());
570        main_render.mount(main.clone(), &state_schema).unwrap();
571        store.commit_render(main_render);
572        assert!(store.get(&settings, "value").is_some());
573
574        store.remove_scope(&main);
575        assert!(store.get(&main, "value").is_none());
576        assert!(store.get(&settings, "value").is_some());
577    }
578}