Skip to main content

tilo_app/framework/
person.rs

1//! The person's own records, as views and surfaces reach them: a resource
2//! kept in the person's own room, beside the record the app shows.
3
4use std::any::Any;
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::rc::Rc;
8
9use tilo_actions::{Action as _, ActionContext, ActionError, ActionInvoker, ChangeKind};
10
11use super::live::Live;
12use super::record::{Held, RecordDoor};
13use super::section::{Opener, SectionDoor, SectionKind, record_kind};
14use tilo_resource::{Invoke, Resource};
15use ui_core::LocalState;
16use ui_core::history_mode::HistoryOwner;
17use ui_core::record::RecordKind;
18use ui_core::record::person::{PersonRecord as StoredRecord, PersonRecords};
19
20/// A resource of the person's own, kept in their own room: what they
21/// watched, the words they taught the keyboard, where they stopped
22/// reading. It follows them to their other devices and never reaches a
23/// collaborator, whatever room the app is open in.
24///
25/// It is a resource like the app's own: its edits are its actions, applied
26/// by the replayable executor, attributed to the person, written into its
27/// history and undone per step ([`Self::undo`]). One record per resource
28/// kind and app, the same on every device, created with the resource's
29/// genesis ([`Resource::initialize`]) where it does not exist yet. Read it
30/// through [`Self::state`] or [`Self::with`]; an edit from another window or
31/// device reaches it on the next frame.
32pub struct PersonRecord<P: Resource> {
33    shared: Rc<Shared<P>>,
34}
35
36impl<P: Resource> Clone for PersonRecord<P> {
37    fn clone(&self) -> Self {
38        Self {
39            shared: Rc::clone(&self.shared),
40        }
41    }
42}
43
44struct Shared<P: Resource> {
45    record: StoredRecord,
46    /// The app that keeps the record, when this app does not change it:
47    /// it is read here and its edits are refused.
48    owner: Option<&'static str>,
49    app_id: &'static str,
50    app_title: &'static str,
51    me: String,
52    /// The state, followed change by change: what views of the record read.
53    live: Live<P>,
54    /// The same state, for controls bound to it.
55    state: LocalState<Rc<P::State>>,
56    /// The record as views and the door edit it.
57    held: RefCell<Option<Rc<Held<P>>>>,
58}
59
60impl<P: Resource> Shared<P> {
61    /// Follow the record when it moved, reading only what changed. Whether
62    /// it did.
63    fn refresh(&self) -> bool {
64        let moved = match self.record.read(|doc| self.live.sync(doc)) {
65            Some(Ok(moved)) => moved,
66            Some(Err(error)) => {
67                tracing::warn!(kind = P::KIND, %error, "person record unreadable");
68                false
69            }
70            None => false,
71        };
72        // Views of the record follow it whole: it is small.
73        let _ = self.live.take_change();
74        if moved {
75            self.state.set(self.live.get());
76        }
77        moved
78    }
79}
80
81impl<P: Resource> PersonRecord<P> {
82    /// The current state.
83    #[must_use]
84    pub fn get(&self) -> Rc<P::State> {
85        self.shared.refresh();
86        self.shared.state.with(Rc::clone)
87    }
88
89    /// Read the current state.
90    pub fn with<T>(&self, read: impl FnOnce(&P::State) -> T) -> T {
91        read(&self.get())
92    }
93
94    /// The state as view state that follows every change, for a control
95    /// bound to it.
96    #[must_use]
97    pub fn state(&self) -> LocalState<Rc<P::State>> {
98        self.shared.state.clone()
99    }
100
101    /// Increases whenever the state changes.
102    #[must_use]
103    pub fn revision(&self) -> u64 {
104        self.shared.refresh();
105        self.shared.live.revision()
106    }
107
108    /// Apply `action` now: one change of the record, attributed to the
109    /// person, recorded in its history and — for content the person made —
110    /// one undo step of theirs.
111    ///
112    /// # Errors
113    /// The action refused to apply, or the record is unavailable.
114    pub fn apply(&self, action: &P::Action) -> Result<(), ActionError> {
115        self.apply_as(action, &ActionInvoker::actor(&self.shared.me))
116    }
117
118    /// [`Self::apply`] by `invoker`: the person, or software acting for
119    /// them.
120    pub(crate) fn apply_as(
121        &self,
122        action: &P::Action,
123        invoker: &ActionInvoker,
124    ) -> Result<(), ActionError> {
125        let shared = &self.shared;
126        if let Some(owner) = shared.owner {
127            return Err(ActionError::Refused(format!(
128                "the person's {} are edited in {owner}",
129                P::LABEL.to_lowercase()
130            )));
131        }
132        let encoded = tilo_resource::encode::<P>(action)?;
133        let change = action.change_kind();
134        let context = ActionContext::for_resource(P::KIND, P::KIND, P::LABEL)
135            .with_app(shared.app_id, shared.app_title)
136            .with_invoker(invoker.clone())
137            .with_change(change);
138        shared
139            .record
140            .edit(change == ChangeKind::Content, |doc| {
141                let writer =
142                    ui_core::history_source::authors::device_actor(&shared.me, doc.get_actor());
143                tilo_actions::apply_local_with_context(doc, action, encoded, Some(&writer), context)
144                    .map(|_| ())
145                    .map_err(|error| error.to_string())
146            })
147            .map_err(ActionError::Refused)?;
148        shared.refresh();
149        Ok(())
150    }
151
152    /// Apply the action `invoke` names, as [`Self::apply`].
153    ///
154    /// # Errors
155    /// The arguments do not build the action, or it refused to apply.
156    pub fn invoke(&self, invoke: &Invoke<P>) -> Result<(), ActionError> {
157        let action = invoke
158            .build(tilo_resource::origin_now_ms())
159            .map_err(ActionError::Refused)?;
160        self.apply(&action)
161    }
162
163    /// Whether this app changes the record: its own, or a shared one it is
164    /// an editor of; any other is read here.
165    #[must_use]
166    pub fn editable(&self) -> bool {
167        self.shared.owner.is_none()
168    }
169
170    /// Undo the person's last edit of the record. Whether there was one.
171    pub fn undo(&self) -> bool {
172        let undone = self.shared.record.undo();
173        self.shared.refresh();
174        undone
175    }
176
177    /// Redo the person's last undone edit of the record.
178    pub fn redo(&self) -> bool {
179        let redone = self.shared.record.redo();
180        self.shared.refresh();
181        redone
182    }
183
184    /// Whether the record reaches the person's other devices: they have a
185    /// root identity, so it lives in their room. Without one it is kept on
186    /// this device only.
187    #[must_use]
188    pub fn syncs(&self) -> bool {
189        self.shared.record.syncs()
190    }
191
192    /// Where the content this record references — a picture its state
193    /// names by hash — is stored and read. It lives with the record, on the
194    /// person's devices, never in the room of the window showing it; `None`
195    /// on a device that knows no person, whose records keep no content.
196    #[must_use]
197    pub fn content(&self) -> Option<tilo_content::ContentStore> {
198        self.shared.record.content()
199    }
200
201    /// The record underneath, for a test or host that carries it between
202    /// devices itself.
203    #[must_use]
204    pub fn record(&self) -> &StoredRecord {
205        &self.shared.record
206    }
207}
208
209/// An open record: the app that owns it, and its kind.
210type OpenKey = (&'static str, String);
211
212/// The person's records a root's views opened, shared by every scope of the
213/// root: one [`PersonRecord`] per resource kind.
214pub(crate) struct PersonState {
215    records: PersonRecords,
216    app_id: &'static str,
217    app_title: &'static str,
218    me: String,
219    /// The open records, by the app that owns them and their kind.
220    opened: RefCell<HashMap<OpenKey, Rc<dyn Any>>>,
221    /// The records of the person's other apps keep, held here too.
222    others: RefCell<HashMap<&'static str, PersonRecords>>,
223    owners: Rc<RefCell<Vec<HistoryOwner>>>,
224    refreshers: RefCell<Vec<Box<dyn Fn() -> bool>>>,
225    /// The open records' undo and redo, as the palette and agents reach
226    /// them.
227    published: RefCell<Vec<Published>>,
228}
229
230/// One open record as the root publishes it.
231struct Published {
232    /// The id it is published under.
233    id: String,
234    label: String,
235    /// Its field edits and verbs, as the door reaches them.
236    door: Rc<dyn RecordDoor>,
237    /// Take back (`true`) or redo (`false`) one of the person's steps, for
238    /// the invoker named. Whether there was one.
239    step: Step,
240}
241
242/// Take back (`true`) or redo (`false`) one step for the invoker named.
243type Step = Box<dyn Fn(bool, &str) -> bool>;
244
245/// A person record just opened as its type: its shared state, its door and
246/// its undo and redo.
247struct Opened {
248    shared: Rc<dyn Any>,
249    door: Rc<dyn RecordDoor>,
250    step: Step,
251}
252
253/// The operation type of a person record's undo and redo.
254pub(crate) const HISTORY_TYPE: &str = "tilo.app.person-history";
255
256/// The most steps one undo or redo of a person record takes.
257const MAX_STEPS: i64 = 50;
258
259/// What a person record's undo or redo was asked.
260#[derive(serde::Serialize, serde::Deserialize)]
261struct HistoryRequest {
262    record: String,
263    undo: bool,
264    steps: Option<i64>,
265}
266
267/// The address a person record of `kind` is published under.
268#[must_use]
269pub(crate) fn resource_id(kind: &str) -> String {
270    format!("person:{kind}")
271}
272
273fn history_spec(record: &str, label: &str, undo: bool) -> tilo_actions::ActionSpec {
274    let mut spec = history_operation(record.to_owned(), label, undo);
275    spec.contract.result = tilo_actions::ResultSpec::Json {
276        schema: serde_json::json!({
277            "type": "object",
278            "properties": { "steps": { "type": "integer" } },
279        }),
280    };
281    spec
282}
283
284fn history_operation(record: String, label: &str, undo: bool) -> tilo_actions::ActionSpec {
285    let (id, verb) = if undo {
286        ("undo", "Undo")
287    } else {
288        ("redo", "Redo")
289    };
290    tilo_actions::ActionSpec::typed_operation(
291        id,
292        verb,
293        verb,
294        tilo_actions::OperationKind::Effect,
295        HISTORY_TYPE,
296        vec![
297            tilo_actions::ActionParam::optional(
298                "steps",
299                tilo_actions::ParamType::Number,
300                "How many of your steps (default 1, at most 50)",
301            )
302            .nullable(),
303        ],
304        move |args| {
305            tilo_actions::EncodedAction::encode_operation(
306                HISTORY_TYPE,
307                &HistoryRequest {
308                    record: record.clone(),
309                    undo,
310                    steps: args.integer("steps"),
311                },
312            )
313            .unwrap_or_else(|_| tilo_actions::EncodedAction {
314                type_id: tilo_actions::resource::INVALID_ARGUMENTS_TYPE.to_owned(),
315                payload: Vec::new(),
316            })
317        },
318    )
319    .of_kind(tilo_actions::ActionKind::Read)
320    .with_description(if undo {
321        format!(
322            "Take back your last change to your {label}: it is yours alone, on all of your devices."
323        )
324    } else {
325        format!("Make again the change to your {label} you last took back.")
326    })
327}
328
329impl PersonState {
330    pub(crate) fn new(
331        records: PersonRecords,
332        app_id: &'static str,
333        app_title: &'static str,
334    ) -> Self {
335        let me = records.author().0.clone();
336        Self {
337            records,
338            app_id,
339            app_title,
340            me,
341            opened: RefCell::default(),
342            others: RefCell::default(),
343            owners: Rc::default(),
344            refreshers: RefCell::default(),
345            published: RefCell::default(),
346        }
347    }
348
349    /// The person's record of `P`, opened on first use.
350    pub(crate) fn open<P: Resource>(&self) -> Option<PersonRecord<P>> {
351        self.open_of::<P>(self.app_id, true)
352    }
353
354    /// The shared record `P`, opened on first use: kept by its owner, a copy
355    /// of it here in every other app, changed where `P` says.
356    pub(crate) fn open_shared<P: tilo_resource::SharedRecord>(&self) -> Option<PersonRecord<P>> {
357        self.open_of::<P>(P::OWNER, P::edited_by(self.app_id))
358    }
359
360    /// The person's record of `P` that the app `owner` keeps, opened on
361    /// first use, and whether this app changes it.
362    fn open_of<P: Resource>(&self, owner: &'static str, edits: bool) -> Option<PersonRecord<P>> {
363        let open = |record: StoredRecord| -> Option<Opened> {
364            let shared = self.shared_of::<P>(record, (!edits).then_some(owner))?;
365            let held = Rc::new(held::<P>(&shared, edits));
366            *shared.held.borrow_mut() = Some(Rc::clone(&held));
367            let weak = Rc::downgrade(&shared);
368            Some(Opened {
369                shared: shared as Rc<dyn Any>,
370                door: held,
371                step: Box::new(move |undo, _| {
372                    weak.upgrade().is_some_and(|shared| {
373                        let record = PersonRecord { shared };
374                        if undo { record.undo() } else { record.redo() }
375                    })
376                }),
377            })
378        };
379        let shared = self.open_kind(
380            owner,
381            P::KIND,
382            P::LABEL,
383            &|doc| P::initialize(doc).map_err(|error| error.to_string()),
384            &open,
385        )?;
386        shared
387            .downcast::<Shared<P>>()
388            .ok()
389            .map(|shared| PersonRecord { shared })
390    }
391
392    /// The open record of `kind` that the app `owner` keeps, or the one
393    /// `open` makes of it, created by `initialize` where it does not exist
394    /// yet, kept and (for this app's own) published: the part of opening a
395    /// person record that does not depend on its type.
396    fn open_kind(
397        &self,
398        owner: &'static str,
399        kind: &'static str,
400        label: &'static str,
401        initialize: &dyn Fn(&mut ui_core::automerge::AutoCommit) -> Result<(), String>,
402        open: &dyn Fn(StoredRecord) -> Option<Opened>,
403    ) -> Option<Rc<dyn Any>> {
404        let key = (owner, kind.to_owned());
405        if let Some(open) = self.opened.borrow().get(&key) {
406            return Some(Rc::clone(open));
407        }
408        let record = open_record(&self.records_of(owner)?, kind.to_owned(), kind, initialize)?;
409        let Opened { shared, door, step } = open(record)?;
410        if owner != self.app_id {
411            self.opened.borrow_mut().insert(key, Rc::clone(&shared));
412            return Some(shared);
413        }
414        self.publish(
415            key,
416            Rc::clone(&shared),
417            Published {
418                id: resource_id(kind),
419                label: label.to_owned(),
420                door,
421                step,
422            },
423        );
424        Some(shared)
425    }
426
427    /// `record` read as `P`, followed from now on.
428    fn shared_of<P: Resource>(
429        &self,
430        record: StoredRecord,
431        owner: Option<&'static str>,
432    ) -> Option<Rc<Shared<P>>> {
433        let reader = record
434            .read(tilo_resource::Reader::<P>::read)?
435            .map_err(|error| tracing::warn!(kind = P::KIND, %error, "person record unreadable"))
436            .ok()?;
437        let live = Live::new(reader);
438        self.owners.borrow_mut().push(record.history_owner());
439        let shared = Rc::new(Shared::<P> {
440            record,
441            owner,
442            app_id: self.app_id,
443            app_title: self.app_title,
444            me: self.me.clone(),
445            state: LocalState::new(live.get()),
446            live,
447            held: RefCell::new(None),
448        });
449        let weak = Rc::downgrade(&shared);
450        self.refreshers.borrow_mut().push(Box::new(move || {
451            weak.upgrade().is_some_and(|shared| shared.refresh())
452        }));
453        Some(shared)
454    }
455
456    /// The settings section `kind` held as a record of the person's, opened
457    /// on first use: its edits are their steps, undone and redone like any
458    /// of theirs, and each writes the section's value to the settings
459    /// document with what made it and who. A device-local section has a
460    /// record per device, as its values do.
461    pub(crate) fn open_section(
462        &self,
463        kind: Rc<SectionKind>,
464        device: Option<&str>,
465    ) -> Option<Rc<SectionDoor>> {
466        let name = record_kind(kind.kind, kind.device_local, device);
467        let key = (self.app_id, name.clone());
468        if let Some(open) = self.opened.borrow().get(&key) {
469            return Rc::clone(open).downcast::<SectionDoor>().ok();
470        }
471        let label = kind.kind;
472        let door = SectionDoor::person(
473            kind,
474            Opener {
475                records: self.records.clone(),
476                name,
477                owners: Rc::clone(&self.owners),
478            },
479            self.app_id,
480            self.app_title,
481            self.me.clone(),
482        )
483        .map_err(|error| tracing::warn!(kind = label, %error, "settings section unavailable"))
484        .ok()?;
485        let door = Rc::new(door);
486        let stepping = Rc::downgrade(&door);
487        self.publish(
488            key,
489            Rc::clone(&door) as Rc<dyn Any>,
490            Published {
491                id: door.id().to_owned(),
492                label: door.label().to_owned(),
493                door: Rc::clone(&door) as Rc<dyn RecordDoor>,
494                step: Box::new(move |undo, by| {
495                    stepping.upgrade().is_some_and(|door| door.step(undo, by))
496                }),
497            },
498        );
499        Some(door)
500    }
501
502    /// The person's records `owner` keeps: this app's own, or another app's,
503    /// opened once.
504    fn records_of(&self, owner: &'static str) -> Option<PersonRecords> {
505        if owner == self.app_id {
506            return Some(self.records.clone());
507        }
508        let mut others = self.others.borrow_mut();
509        if let Some(records) = others.get(owner) {
510            return Some(records.clone());
511        }
512        let records = self
513            .records
514            .of_package(owner)
515            .map_err(|error| {
516                tracing::warn!(owner, %error, "the person's records of another app are unavailable");
517            })
518            .ok()?;
519        others.insert(owner, records.clone());
520        Some(records)
521    }
522
523    /// Keep the record opened under `key`, publishing its door and its undo
524    /// and redo.
525    fn publish(&self, key: OpenKey, open: Rc<dyn Any>, published: Published) {
526        self.published.borrow_mut().push(published);
527        self.opened.borrow_mut().insert(key, open);
528    }
529
530    /// Read every open record that moved. Whether one did.
531    pub(crate) fn refresh(&self) -> bool {
532        self.refreshers
533            .borrow()
534            .iter()
535            .fold(false, |moved, refresh| refresh() | moved)
536    }
537
538    /// The open records as resources of their own: the person's undo and
539    /// redo of each, which the palette and agents reach like the app's own
540    /// history.
541    pub(crate) fn resources(&self) -> Vec<tilo_actions::Resource> {
542        self.published
543            .borrow()
544            .iter()
545            .map(|published| {
546                published
547                    .door
548                    .publish()
549                    .with_action(history_spec(&published.id, &published.label, true))
550                    .with_action(history_spec(&published.id, &published.label, false))
551            })
552            .collect()
553    }
554
555    /// The open records of the person's own, as the door changes them.
556    pub(crate) fn doors(&self) -> Vec<Rc<dyn RecordDoor>> {
557        self.published
558            .borrow()
559            .iter()
560            .map(|published| Rc::clone(&published.door))
561            .collect()
562    }
563
564    /// Who the records are of.
565    pub(crate) fn me(&self) -> &str {
566        &self.me
567    }
568
569    /// Answer an undo or redo of an open record: how many steps it took.
570    pub(crate) fn answer(&self, payload: &[u8]) -> Result<tilo_actions::ActionOutput, String> {
571        let request: HistoryRequest =
572            tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
573        let steps = request.steps.unwrap_or(1);
574        if !(1..=MAX_STEPS).contains(&steps) {
575            return Err(format!("steps is 1 to {MAX_STEPS}"));
576        }
577        let invoker = ui_core::invocation_scope::current_invoker()
578            .unwrap_or_else(|| ActionInvoker::actor(&self.me));
579        if invoker.actor != self.me && invoker.on_behalf_of.as_deref() != Some(self.me.as_str()) {
580            return Err(format!(
581                "`{}` is {}'s own; only they change it",
582                request.record, self.me
583            ));
584        }
585        let published = self.published.borrow();
586        let record = published
587            .iter()
588            .find(|published| published.id == request.record)
589            .ok_or_else(|| format!("no record of yours is open as {}", request.record))?;
590        let mut taken = 0;
591        while taken < steps && (record.step)(request.undo, &invoker.actor) {
592            taken = taken.saturating_add(1);
593        }
594        if taken == 0 {
595            return Err(if request.undo {
596                "nothing of yours to undo".to_owned()
597            } else {
598                "nothing of yours to redo".to_owned()
599            });
600        }
601        Ok(tilo_actions::ActionOutput {
602            value: Some(serde_json::json!({ "steps": taken })),
603            resources: vec![record.id.clone()],
604        })
605    }
606
607    /// The routes of the open records, which the runtime syncs.
608    pub(crate) fn owners(&self) -> Vec<HistoryOwner> {
609        self.owners.borrow().clone()
610    }
611}
612
613/// Open the record `kind` of `records`, created by `initialize` where it does
614/// not exist yet; a failure is logged for the resource `of`.
615fn open_record(
616    records: &PersonRecords,
617    kind: String,
618    of: &str,
619    initialize: &dyn Fn(&mut ui_core::automerge::AutoCommit) -> Result<(), String>,
620) -> Option<StoredRecord> {
621    records
622        .open_record(RecordKind::new(kind), initialize)
623        .map_err(|error| tracing::warn!(kind = of, %error, "person record unavailable"))
624        .ok()
625}
626
627/// The record `shared` as views and the door edit it: read into a state of
628/// its own that follows the record, changed as the person.
629fn held<P: Resource>(shared: &Rc<Shared<P>>, edits: bool) -> Held<P> {
630    let following = Rc::downgrade(shared);
631    let applying = Rc::downgrade(shared);
632    Held::new(
633        resource_id(P::KIND),
634        format!("Your {}", P::LABEL.to_lowercase()),
635        edits,
636        shared.live.clone(),
637        move |_: &Live<P>| {
638            if let Some(shared) = following.upgrade() {
639                shared.refresh();
640            }
641        },
642        move |action: &P::Action, invoker: &ActionInvoker| {
643            let shared = applying
644                .upgrade()
645                .ok_or_else(|| ActionError::Refused("the record is closed".into()))?;
646            PersonRecord { shared }.apply_as(action, invoker)
647        },
648    )
649}
650
651impl<P: Resource> super::record::sealed::Hold<P> for PersonRecord<P> {
652    fn held(&self, _storage: super::record::sealed::Storage<'_>) -> Option<Rc<Held<P>>> {
653        self.shared.held.borrow().clone()
654    }
655
656    fn door(
657        &self,
658        _storage: super::record::sealed::Storage<'_>,
659    ) -> Option<super::record::sealed::Door> {
660        self.shared
661            .held
662            .borrow()
663            .clone()
664            .map(|held| super::record::sealed::Door(held as Rc<dyn RecordDoor>))
665    }
666}
667
668impl Drop for PersonState {
669    /// The window closes: what the person did is stored before it goes.
670    fn drop(&mut self) {
671        if let Err(error) = self.records.flush() {
672            tracing::warn!(%error, "the person's records could not be stored");
673        }
674    }
675}