Skip to main content

tilo_app/framework/
section.rs

1//! A settings section held as a record ([`RecordEnv::settings`](super::RecordEnv::settings)):
2//! published as `settings:<kind>` with its field edits and verbs, each change
3//! checked by the section's model and stored in the settings document with
4//! what made it and who.
5//!
6//! The settings document is the section's only copy: the record reads the
7//! section as the document stores it, an action runs on that value and what
8//! it changed is written back to the document as one change, and undo and
9//! redo of an invoker's steps read and write the document's own history.
10//! Every section is kept by the one [`SectionDoor`]; what it needs of the
11//! section's type is a [`SectionKind`], a handful of functions built once
12//! per type; nothing else is compiled per section.
13
14use std::cell::{Cell, RefCell};
15use std::rc::Rc;
16
17use tilo_actions::{Action, ActionError, ActionInvoker, EncodedAction};
18use tilo_resource::{Doc, Fields, Reader, Resource};
19use ui_core::history_source::{ThreadWords, steps};
20use ui_core::storage::{
21    ChangeOrigin, InvokingAction, Settings, SettingsDocument, SettingsRevision, SettingsSchema,
22};
23
24use super::live::Live;
25use super::record::{Held, RecordDoor};
26
27/// One thing a section's record asks of the section's type.
28#[derive(Clone, Copy)]
29enum SectionOp<'a> {
30    /// Run the action on the section as stored, and store what it made as
31    /// the change the origin made.
32    Apply(&'a dyn Action, &'a ChangeOrigin),
33    /// The section as stored, published under an id and a label.
34    Publish(&'a str, &'a str),
35    Decode(&'a EncodedAction),
36    /// The section as stored now.
37    Stored,
38}
39
40/// What a [`SectionOp`] answers.
41enum SectionOut {
42    Done,
43    Published(Box<tilo_actions::ResourceSpec>),
44    Decoded(Box<dyn Action>),
45    Value(serde_json::Value),
46}
47
48/// What a section's record does with the section's type: one function per
49/// section type ([`SectionOp`]), over the section's settings
50/// ([`Self::settings`]).
51pub(crate) struct SectionKind {
52    pub(crate) kind: &'static str,
53    pub(crate) label: &'static str,
54    action_type: &'static str,
55    /// The section of the settings document the value is kept in.
56    section: &'static str,
57    /// The app whose settings the section is, and the document they live in.
58    app_id: String,
59    document: SettingsDocument,
60    ops: fn(&Self, SectionOp<'_>) -> Result<SectionOut, ActionError>,
61}
62
63impl SectionKind {
64    pub(crate) fn of<T>(settings: &Settings<T>) -> Rc<Self>
65    where
66        T: Resource<State = T> + SettingsSchema + Fields,
67    {
68        Rc::new(Self {
69            kind: T::KIND,
70            label: T::LABEL,
71            action_type: T::ACTION_TYPE,
72            section: T::SCOPE.section(),
73            app_id: settings.app_id().to_owned(),
74            document: settings.document().clone(),
75            ops: section_ops::<T>,
76        })
77    }
78
79    /// The section's settings as `T`, the type this kind was made of.
80    fn settings<T: SettingsSchema>(&self) -> Settings<T> {
81        Settings::for_app(&self.app_id).in_document(self.document.clone())
82    }
83
84    fn revision(&self) -> SettingsRevision {
85        self.document.revision()
86    }
87
88    /// Run `action`, the action `id` by `by`, on the section, run by the
89    /// action `invoked_by` when another action ran it, and store what it
90    /// made.
91    fn apply(
92        &self,
93        action: &dyn Action,
94        id: &str,
95        by: &str,
96        invoked_by: Option<&InvokingAction>,
97    ) -> Result<(), ActionError> {
98        let origin = ChangeOrigin::action(format!("{}/{id}", self.kind))
99            .by(by.to_owned())
100            .invoked_by(invoked_by.cloned());
101        (self.ops)(self, SectionOp::Apply(action, &origin)).map(drop)
102    }
103
104    fn publish(&self, id: &str) -> Result<tilo_actions::ResourceSpec, ActionError> {
105        match (self.ops)(self, SectionOp::Publish(id, self.label))? {
106            SectionOut::Published(resource) => Ok(*resource),
107            _ => Err(ActionError::Refused("the section published nothing".into())),
108        }
109    }
110
111    fn decode(&self, action: &EncodedAction) -> Result<Box<dyn Action>, ActionError> {
112        match (self.ops)(self, SectionOp::Decode(action))? {
113            SectionOut::Decoded(action) => Ok(action),
114            _ => Err(ActionError::Refused("the section decoded nothing".into())),
115        }
116    }
117
118    /// The section as stored now, as its model writes it.
119    fn stored(&self) -> Result<serde_json::Value, ActionError> {
120        match (self.ops)(self, SectionOp::Stored)? {
121            SectionOut::Value(value) => Ok(value),
122            _ => Err(ActionError::Refused("the section read nothing".into())),
123        }
124    }
125
126    /// Take back (`undo`) or bring back the last step `by` took on the
127    /// section, reading the settings document's history: their changes
128    /// through the section's actions, or ones under an `alias` of theirs
129    /// (an id they went by before this device had an identity). Whether
130    /// there was one.
131    fn step(&self, undo: bool, by: &str, aliases: &[String]) -> Result<bool, String> {
132        let verb = if undo { "undo" } else { "redo" };
133        let origin = ChangeOrigin::action(format!("{}/{verb}", self.kind)).by(by.to_owned());
134        let actions = format!("{}/", self.kind);
135        let owns = |version: &tilo_resource::history::Version| {
136            version.message.as_deref().is_some_and(|message| {
137                ui_core::storage::made_by(message, &actions, by)
138                    || aliases
139                        .iter()
140                        .any(|alias| ui_core::storage::made_by(message, &actions, alias))
141            })
142        };
143        let mut stepped = false;
144        self.document
145            .edit_document(self.section, &origin, |doc| {
146                // A section's actions queue nothing outside the document:
147                // a step is taken back by reverting what it wrote.
148                let steps = if undo {
149                    steps::undo_where(doc, &owns, 1, |_| Ok(None), &ThreadWords)
150                } else {
151                    steps::redo_where(doc, &owns, 1, |_| Ok(None), &ThreadWords)
152                }?;
153                stepped = !steps.is_empty();
154                Ok(())
155            })
156            .map_err(|error| error.to_string())?;
157        Ok(stepped)
158    }
159}
160
161/// Everything a section's record asks of the section type `T`.
162fn section_ops<T>(kind: &SectionKind, op: SectionOp<'_>) -> Result<SectionOut, ActionError>
163where
164    T: Resource<State = T> + SettingsSchema + Fields,
165{
166    let refused = |error: &dyn std::fmt::Display| ActionError::Refused(error.to_string());
167    Ok(match op {
168        SectionOp::Apply(action, origin) => {
169            // A resource's actions apply to a record at a document's root:
170            // the section's value is laid out as one for the action to run
171            // on, and what it made is stored as the section's change.
172            let root = &tilo_resource::__private::ROOT;
173            let settings = kind.settings::<T>();
174            let mut record = Doc::new();
175            T::initialize(&mut record).map_err(|error| refused(&error))?;
176            settings
177                .get()
178                .write_fields(&mut record, root)
179                .map_err(|error| refused(&error))?;
180            record.commit();
181            action.apply(&mut record)?;
182            let value = T::read_fields(&record, root).map_err(|error| refused(&error))?;
183            settings
184                .set_by(&value, Some(origin))
185                .map_err(|error| refused(&error))?;
186            SectionOut::Done
187        }
188        SectionOp::Publish(id, label) => SectionOut::Published(Box::new(
189            tilo_resource::publish::<T>(id, label, &kind.settings::<T>().get()),
190        )),
191        SectionOp::Decode(action) => SectionOut::Decoded(Box::new(
192            tilo_actions::decode_json::<T::Action>(&action.payload)
193                .map_err(|error| refused(&error))?,
194        )),
195        SectionOp::Stored => {
196            SectionOut::Value(tilo_resource::Model::to_json(&kind.settings::<T>().get()))
197        }
198    })
199}
200
201/// A settings section held as a record, whatever its type.
202pub(crate) struct SectionDoor {
203    id: String,
204    kind: Rc<SectionKind>,
205    /// The person whose steps the section's undo and redo take back, and
206    /// the ids they went by before this device had an identity: changes
207    /// made under any of them are theirs.
208    me: String,
209    earlier: Vec<String>,
210    /// The settings document's version the door last read.
211    seen: Cell<Option<SettingsRevision>>,
212    /// Increases whenever what the section holds may have changed.
213    revision: Cell<u64>,
214    /// The last publication, at the door's and the locale's revision it was
215    /// built at: published again only when either moved.
216    published: RefCell<Option<((u64, u64), tilo_actions::ResourceSpec)>>,
217}
218
219impl SectionDoor {
220    pub(crate) fn new(kind: Rc<SectionKind>, me: String, earlier: Vec<String>) -> Self {
221        Self {
222            id: format!("settings:{}", kind.kind),
223            kind,
224            me,
225            earlier,
226            seen: Cell::new(None),
227            revision: Cell::new(0),
228            published: RefCell::new(None),
229        }
230    }
231
232    pub(crate) fn label(&self) -> &'static str {
233        self.kind.label
234    }
235
236    fn moved(&self) {
237        self.revision.set(self.revision.get().wrapping_add(1));
238    }
239
240    /// Note whether the settings document moved since the door last read
241    /// it (another window, the Settings app, another device changed it).
242    fn notice(&self) {
243        let now = Some(self.kind.revision());
244        if self.seen.get() != now {
245            self.seen.set(now);
246            self.moved();
247        }
248    }
249
250    /// Apply the encoded action `encoded` for `invoker`, run by the action
251    /// `invoked_by` when another action ran it, and store what it made as
252    /// what made it.
253    fn apply(
254        &self,
255        encoded: &EncodedAction,
256        invoker: &ActionInvoker,
257        invoked_by: Option<&InvokingAction>,
258    ) -> Result<(), ActionError> {
259        let action = self.kind.decode(encoded)?;
260        let id = action_id(&encoded.payload);
261        self.kind
262            .apply(action.as_ref(), &id, &invoker.actor, invoked_by)?;
263        self.notice();
264        Ok(())
265    }
266
267    /// Take back (`undo`) or redo the last step `by` took on the section.
268    /// Whether there was one.
269    pub(crate) fn step(&self, undo: bool, by: &str) -> bool {
270        // What an id they went by made is theirs to take back; what an agent
271        // acting for them made is the agent's own.
272        let aliases: &[String] = if by == self.me { &self.earlier } else { &[] };
273        match self.kind.step(undo, by, aliases) {
274            Ok(stepped) => {
275                self.notice();
276                stepped
277            }
278            Err(error) => {
279                tracing::warn!(kind = self.kind.kind, %error, "settings section not stepped");
280                false
281            }
282        }
283    }
284
285    /// The section, as a record its views edit through its type: a view part
286    /// over it ([`ViewCx::record`](super::ViewCx::record)).
287    pub(crate) fn held<T>(self: &Rc<Self>) -> Held<T>
288    where
289        T: Resource<State = T> + SettingsSchema + Fields,
290    {
291        let settings = self.kind.settings::<T>();
292        let seen = Cell::new(settings.revision());
293        let live = Live::new(Reader::with_state(settings.get(), None));
294        let applying = Rc::clone(self);
295        Held::new(
296            self.id.clone(),
297            self.kind.label.to_owned(),
298            true,
299            live,
300            move |live: &Live<T>| {
301                let now = settings.revision();
302                if now == seen.get() {
303                    return;
304                }
305                seen.set(now);
306                let stored = settings.get();
307                if tilo_resource::Model::to_json(&stored)
308                    != live.with(tilo_resource::Model::to_json)
309                {
310                    live.replace(stored);
311                }
312            },
313            move |action: &T::Action, invoker: &ActionInvoker| {
314                applying.apply(&tilo_resource::encode::<T>(action)?, invoker, None)
315            },
316        )
317    }
318}
319
320impl RecordDoor for SectionDoor {
321    fn id(&self) -> &str {
322        &self.id
323    }
324
325    fn editable(&self) -> bool {
326        true
327    }
328
329    fn revision(&self) -> u64 {
330        self.notice();
331        self.revision.get()
332    }
333
334    fn publish(&self) -> tilo_actions::ResourceSpec {
335        self.notice();
336        let revision = (self.revision.get(), ui_core::i18n::I18nManager::revision());
337        if let Some((at, published)) = self.published.borrow().as_ref()
338            && *at == revision
339        {
340            return published.clone();
341        }
342        let published = self.kind.publish(&self.id).unwrap_or_else(|error| {
343            tracing::warn!(kind = self.kind.kind, %error, "settings section unreadable");
344            tilo_actions::ResourceSpec::new(self.id.as_str(), self.kind.kind, self.kind.label)
345        });
346        let published = super::record::route(published, &self.id, true);
347        *self.published.borrow_mut() = Some((revision, published.clone()));
348        published
349    }
350
351    fn apply_encoded(&self, action: &EncodedAction, invoker: &ActionInvoker) -> Result<(), String> {
352        self.apply_invoked(action, invoker, None)
353    }
354}
355
356impl SectionDoor {
357    /// [`RecordDoor::apply_encoded`], run by the action `invoked_by` when
358    /// another action ran this one.
359    fn apply_invoked(
360        &self,
361        action: &EncodedAction,
362        invoker: &ActionInvoker,
363        invoked_by: Option<&InvokingAction>,
364    ) -> Result<(), String> {
365        if action.type_id != self.kind.action_type {
366            return Err(format!(
367                "{} takes no `{}` action",
368                self.kind.label, action.type_id
369            ));
370        }
371        self.apply(action, invoker, invoked_by)
372            .map_err(|error| error.to_string())
373    }
374}
375
376/// The settings sections an app keeps as records, held by a host without a
377/// window: the session, which keeps them for the palette and agents whether
378/// or not the app is open. Each is published and changed as the app's
379/// window does it, so a change checks the section's model and is recorded
380/// in the settings document with what made it and who; admitting an
381/// invocation (the invoker's grants) is the caller's, from the published
382/// action.
383pub struct SettingsRecords {
384    doors: Vec<Rc<SectionDoor>>,
385    /// The person the sections are changed by, whose undo and redo of
386    /// their steps on each are published beside it, with their shared
387    /// records held beside the sections.
388    person: Option<Rc<super::person::PersonState>>,
389}
390
391impl SettingsRecords {
392    /// The settings sections `declare` declares, of the app `app_id`, as
393    /// they are stored now.
394    #[must_use]
395    pub fn new(app_id: &'static str, declare: super::Declare) -> Self {
396        Self::in_document(app_id, &[declare], SettingsDocument::Session)
397    }
398
399    /// The settings sections `declares` declare, of the app `app_id`, as
400    /// `document` stores them.
401    #[must_use]
402    pub fn in_document(
403        app_id: &'static str,
404        declares: &[super::Declare],
405        document: SettingsDocument,
406    ) -> Self {
407        Self::declared(
408            super::record::Sections {
409                app_id,
410                document,
411                person: None,
412                doors: Vec::new(),
413            },
414            declares,
415        )
416    }
417
418    /// The settings sections `declares` declare, of the app `app_id` (titled
419    /// `app_title`), as the app's window keeps them for the person whose
420    /// `records` hold their shared records: each change is stored in the
421    /// session's settings document with what made it and who, and is a step
422    /// of its invoker's, undone and redone like any of theirs.
423    #[must_use]
424    pub fn of_person(
425        app_id: &'static str,
426        app_title: &'static str,
427        declares: &[super::Declare],
428        records: ui_core::record::person::PersonRecords,
429    ) -> Self {
430        Self::declared(
431            super::record::Sections {
432                app_id,
433                document: SettingsDocument::Session,
434                person: Some(Rc::new(super::person::PersonState::new(
435                    records, None, app_id, app_title,
436                ))),
437                doors: Vec::new(),
438            },
439            declares,
440        )
441    }
442
443    /// The settings sections `declares` declare, of the app `app_id` (titled
444    /// `app_title`), changed by `person`: each change is stored in the
445    /// session's settings document with what made it and who, and is a step
446    /// of its invoker's, undone and redone like any of theirs. `earlier`
447    /// are the ids they went by before this device had an identity: what
448    /// was changed under one is theirs to take back. The sections' history
449    /// is the settings document's, so the host holds no person records
450    /// beside them.
451    #[must_use]
452    pub fn of_person_named(
453        app_id: &'static str,
454        app_title: &'static str,
455        person: &str,
456        earlier: &[String],
457        declares: &[super::Declare],
458    ) -> Self {
459        Self::declared(
460            super::record::Sections {
461                app_id,
462                document: SettingsDocument::Session,
463                person: Some(Rc::new(super::person::PersonState::without_records(
464                    person.to_owned(),
465                    earlier.to_vec(),
466                    app_id,
467                    app_title,
468                ))),
469                doors: Vec::new(),
470            },
471            declares,
472        )
473    }
474
475    fn declared(sections: super::record::Sections, declares: &[super::Declare]) -> Self {
476        let person = sections.person.clone();
477        let mut records = super::Records::sections(sections);
478        for declare in declares {
479            declare(&mut records);
480        }
481        Self {
482            doors: records.into_sections(),
483            person,
484        }
485    }
486
487    /// The person's shared records held beside the sections
488    /// ([`Self::shared`]), which the runtime holds for the person; none
489    /// where no person is.
490    #[must_use]
491    pub fn history_owners(&self) -> Vec<ui_core::history_mode::HistoryOwner> {
492        self.person
493            .as_ref()
494            .map(|person| person.owners())
495            .unwrap_or_default()
496    }
497
498    /// The person's shared record `P` (their accounts, their contacts),
499    /// kept in the person's own scope
500    /// ([`Self::history_owners`]), opened on first use; not published here.
501    /// `None` where no person is.
502    #[must_use]
503    pub fn shared<P: tilo_resource::SharedRecord>(&self) -> Option<super::PersonRecord<P>> {
504        self.person.as_ref()?.hold_shared::<P>()
505    }
506
507    /// Every section, published with its field edits and verbs, and with
508    /// the undo and redo of its invoker's steps where a person is.
509    #[must_use]
510    pub fn resources(&self) -> Vec<tilo_actions::ResourceSpec> {
511        match &self.person {
512            Some(person) => person.resources(),
513            None => self.doors.iter().map(|door| door.publish()).collect(),
514        }
515    }
516
517    /// The section `kind` (`settings.appearance`) as stored now, as its
518    /// model writes it: what a control showing one of its fields shows.
519    /// `None` for a section not declared here.
520    #[must_use]
521    pub fn stored(&self, kind: &str) -> Option<serde_json::Value> {
522        let door = self.doors.iter().find(|door| door.kind.kind == kind)?;
523        door.kind
524            .stored()
525            .map_err(|error| tracing::warn!(kind, %error, "settings section unreadable"))
526            .ok()
527    }
528
529    /// Run the action at `address` with its typed `arguments` for `invoker`,
530    /// on behalf of the action `invoked_by` when another action runs it
531    /// (the palette's dark mode runs the appearance field edit). The change
532    /// is recorded with both, so its history tells them apart. A change
533    /// whose published action asks first, for the value it carries (a later
534    /// lock), runs only once it is `confirmed`.
535    pub fn invoke(
536        &self,
537        address: &tilo_actions::ActionAddress,
538        arguments: &serde_json::Map<String, serde_json::Value>,
539        confirmed: bool,
540        invoker: &ActionInvoker,
541        invoked_by: Option<&InvokingAction>,
542    ) -> Result<tilo_actions::ActionOutput, tilo_actions::DoorError> {
543        let resources = self.resources();
544        let spec = tilo_actions::door::resolve(&resources, address)
545            .ok_or_else(|| tilo_actions::DoorError::Unknown(address.clone()))?;
546        if !confirmed
547            && spec.confirmation_for_json(arguments) != tilo_actions::ConfirmationPolicy::None
548        {
549            return Err(tilo_actions::DoorError::ConfirmationRequired);
550        }
551        let encoded = tilo_actions::door::encode_json(&resources, address, arguments)?;
552        if encoded.type_id == super::person::HISTORY_TYPE
553            && let Some(person) = &self.person
554        {
555            return person
556                .answer_for(&encoded.payload, invoker)
557                .map_err(tilo_actions::DoorError::Failed);
558        }
559        let request: super::record::RecordAction = tilo_actions::decode_json(&encoded.payload)
560            .map_err(|error| tilo_actions::DoorError::InvalidArguments(error.to_string()))?;
561        let door = self
562            .doors
563            .iter()
564            .find(|door| door.id() == request.record)
565            .ok_or_else(|| tilo_actions::DoorError::Unknown(address.clone()))?;
566        door.apply_invoked(&request.action, invoker, invoked_by)
567            .map(|()| tilo_actions::ActionOutput::default())
568            .map_err(tilo_actions::DoorError::Failed)
569    }
570}
571
572/// The public id of the action whose encoded payload is `payload`.
573fn action_id(payload: &[u8]) -> String {
574    serde_json::from_slice::<serde_json::Value>(payload)
575        // ignored-failure: a payload that does not read back is named by the generic label
576        .ok()
577        .and_then(|value| {
578            let operation = value.get("op").unwrap_or(&value);
579            operation
580                .get("action")
581                .and_then(serde_json::Value::as_str)
582                .map(str::to_owned)
583        })
584        .unwrap_or_else(|| "action".to_owned())
585}