Skip to main content

tilo_app/framework/
preview.rs

1//! History mode for every framework app: past versions rendered by the app's
2//! own view, read-only.
3//!
4//! A view is a function of the resource state, so a historical version needs
5//! no separate renderer: the framework reads the resource from the frozen
6//! document, builds the view again with editing disabled, and lays it out in
7//! History mode's preview pane. Restoring a version is equally generic: the
8//! restore schema comes from the resource's stored [`Shape`].
9
10use std::any::TypeId;
11
12use tilo_resource::{Reader, Resource, Shape};
13use ui_core::automerge::AutoCommit;
14use ui_core::history_mode::restore::{
15    HistoryMapEntries, HistoryMapField, HistoryMapOverlay, HistoryMembership, HistoryRestoreNode,
16    HistoryRestoreSchema, HistoryRootPattern, HistoryRootSelector, PreservedOnCreate,
17    RestoreValueType,
18};
19use ui_core::history_mode::{
20    FrozenRootPreview, HistoricalGap, HistoricalSupport, HistoryDefinition, HistoryPresentation,
21    HistoryPreview, HistoryScopeDefinition, PreviewInput, PreviewView,
22};
23use ui_core::vello::kurbo::Rect;
24
25use super::app::{Editing, ResourceApp, build_root, leaked};
26use super::live::Live;
27use super::root::ResourceRoot;
28use ui_core::storage::AppStorage;
29
30struct HistoryKey<A>(std::marker::PhantomData<A>);
31
32struct FileWitnessKey<A>(std::marker::PhantomData<A>);
33
34/// The file scope of `A`'s History alone: what its root declares it owns, so
35/// the runner can tell the file source it was handed is the root's.
36pub(crate) fn file_history_witness<A: ResourceApp>() -> &'static HistoryDefinition {
37    // The definition table is not reentrant: read the whole definition first.
38    let definition = history_definition::<A>();
39    leaked(TypeId::of::<FileWitnessKey<A>>(), || {
40        file_witness(definition)
41    })
42}
43
44/// [`file_history_witness`] of the app whose History is `definition`. Not
45/// generic, so it is compiled once rather than for every app.
46#[inline(never)]
47fn file_witness(definition: &HistoryDefinition) -> HistoryDefinition {
48    let scopes: &'static [HistoryScopeDefinition] = Box::leak(
49        definition
50            .scopes
51            .iter()
52            .filter(|scope| scope.key == FILE_SCOPE)
53            .map(|scope| HistoryScopeDefinition {
54                key: scope.key,
55                title: scope.title,
56                presentation: match scope.presentation {
57                    HistoryPresentation::CheckedFiles { create } => {
58                        HistoryPresentation::CheckedFiles { create }
59                    }
60                    _ => unreachable_scope(),
61                },
62                content_schema: None,
63            })
64            .collect::<Box<[_]>>(),
65    );
66    HistoryDefinition {
67        app_id: definition.app_id,
68        scopes,
69    }
70}
71
72#[allow(
73    clippy::panic,
74    reason = "the file scope is built as a checked-file scope above"
75)]
76fn unreachable_scope() -> ! {
77    panic!("the file History scope is a checked-file scope")
78}
79
80/// The History scope of the file an app of
81/// [`ResourceApp::FILE_HISTORY`](super::ResourceApp::FILE_HISTORY) shows.
82const FILE_SCOPE: &str = "file";
83
84/// The History mode registration of `A`: one scope, previewed by its view.
85pub(crate) fn history_definition<A: ResourceApp>() -> &'static HistoryDefinition {
86    leaked(TypeId::of::<HistoryKey<A>>(), || {
87        history_definition_of(
88            super::app::hooks::<A>().id(),
89            A::FILE_HISTORY,
90            (<A::Resource as Resource>::LABEL, preview::<A>),
91            restore_schema::<A::Resource>(),
92        )
93    })
94}
95
96/// [`history_definition`] of the app `app_id`: its resource's scope, called
97/// `label` and previewed by `create`, and the scope of `file` when it shows
98/// files. Not generic, so it is compiled once rather than for every app.
99#[inline(never)]
100fn history_definition_of(
101    app_id: &'static str,
102    file: Option<super::app::FileHistory>,
103    (label, create): (&'static str, ui_core::history_mode::PreviewFactory),
104    content_schema: Option<&'static HistoryRestoreSchema>,
105) -> HistoryDefinition {
106    // A file of the mounted filesystem the app shows is a scope beside
107    // the resource's; History moves to it when the app opens a file's.
108    let file = file.map(|file| HistoryScopeDefinition {
109        key: FILE_SCOPE,
110        title: file.title,
111        presentation: HistoryPresentation::CheckedFiles {
112            create: file.preview,
113        },
114        content_schema: None,
115    });
116    let resource = HistoryScopeDefinition {
117        key: "resource",
118        title: label,
119        presentation: HistoryPresentation::Domain { create },
120        content_schema,
121    };
122    let scopes: &'static [HistoryScopeDefinition] =
123        Box::leak(std::iter::once(resource).chain(file).collect::<Box<[_]>>());
124    HistoryDefinition { app_id, scopes }
125}
126
127/// The History restore schema of `R`, derived from its stored layout:
128/// restoring a version writes it back field by field and collection item by
129/// item, the way actions write it, so unchanged content keeps its identity and
130/// a collaborator's concurrent edits to other items survive. Every field is
131/// optional (a record reads absent fields as their empty value) and whatever a
132/// field holds is accepted by type; the resource's own `read` is the validator,
133/// so a version is restorable exactly when the app can open it.
134fn restore_schema<R: Resource>() -> Option<&'static HistoryRestoreSchema> {
135    restore_schema_of(R::shape(), R::KIND, R::VERSION, readable::<R>)
136}
137
138/// [`restore_schema`] of the resource `kind` stored as `shape` at `version`,
139/// which `readable` validates. The schema reads the version the resource
140/// reads, so a restore is admitted exactly for the records the app opens. Not
141/// generic, so it is compiled once rather than for every resource type.
142#[inline(never)]
143fn restore_schema_of(
144    shape: Option<Shape>,
145    kind: &'static str,
146    version: u32,
147    readable: fn(&AutoCommit) -> Result<(), String>,
148) -> Option<&'static HistoryRestoreSchema> {
149    let Some(Shape::Record(fields)) = shape else {
150        return None;
151    };
152    if fields.is_empty() {
153        return None;
154    }
155    let selectors = fields
156        .iter()
157        .map(|field| HistoryRootSelector {
158            pattern: HistoryRootPattern::Exact(field.key),
159            required: false,
160            node: restore_node(field.shape),
161        })
162        .collect::<Vec<_>>();
163    Some(Box::leak(Box::new(HistoryRestoreSchema {
164        id: kind,
165        version,
166        roots: &[],
167        selectors: Box::leak(selectors.into_boxed_slice()),
168        preserved_roots: &[],
169        preserved: &[],
170        validate: readable,
171        validate_result: readable,
172    })))
173}
174
175fn restore_node(shape: Shape) -> HistoryRestoreNode {
176    let whole = HistoryRestoreNode::Whole {
177        expected_type: RestoreValueType::Any,
178    };
179    let overlay = |fields: Vec<HistoryMapField>, entries: Option<&'static HistoryMapEntries>| {
180        HistoryRestoreNode::MapOverlay(Box::leak(Box::new(HistoryMapOverlay {
181            fields: Box::leak(fields.into_boxed_slice()),
182            entries,
183        })))
184    };
185    match shape {
186        Shape::Record(fields) if !fields.is_empty() => overlay(
187            fields
188                .iter()
189                .map(|field| HistoryMapField {
190                    key: field.key,
191                    required: false,
192                    node: restore_node(field.shape),
193                })
194                .collect(),
195            None,
196        ),
197        Shape::Record(_) | Shape::Value | Shape::Map(_) => whole,
198        Shape::Keyed(item) => overlay(
199            Vec::new(),
200            Some(Box::leak(Box::new(HistoryMapEntries {
201                key_limit_bytes: 1024,
202                node: restore_node(*item),
203                membership: HistoryMembership::TargetAuthoritative,
204                on_create: PreservedOnCreate::Omit,
205            }))),
206        ),
207        Shape::Collection(item) => {
208            let entry = overlay(
209                vec![
210                    HistoryMapField {
211                        key: tilo_resource::shape::ENTRY_ORDER_KEY,
212                        required: false,
213                        node: whole,
214                    },
215                    HistoryMapField {
216                        key: tilo_resource::shape::ENTRY_VALUE_KEY,
217                        required: false,
218                        node: restore_node(*item),
219                    },
220                ],
221                None,
222            );
223            overlay(
224                Vec::new(),
225                Some(Box::leak(Box::new(HistoryMapEntries {
226                    key_limit_bytes: 1024,
227                    node: entry,
228                    membership: HistoryMembership::TargetAuthoritative,
229                    on_create: PreservedOnCreate::Omit,
230                }))),
231            )
232        }
233    }
234}
235
236/// A version is valid content exactly when the resource reads from it.
237fn readable<R: Resource>(doc: &AutoCommit) -> Result<(), String> {
238    R::read(doc).map(drop).map_err(|error| error.to_string())
239}
240
241/// What History can do with `R`'s versions, from what the type declared:
242/// its restore schema (see [`restore_schema`]) and whether it has a read
243/// adapter.
244#[must_use]
245pub(crate) fn historical_support<R: Resource>() -> HistoricalSupport {
246    HistoricalSupport::of(
247        &HistoryScopeDefinition {
248            key: "resource",
249            title: R::LABEL,
250            presentation: HistoryPresentation::Domain {
251                create: unavailable_preview,
252            },
253            content_schema: restore_schema::<R>(),
254        },
255        R::READABLE,
256    )
257}
258
259#[allow(
260    clippy::unnecessary_wraps,
261    reason = "a HistoryPresentation factory's signature, never called"
262)]
263fn unavailable_preview(_: PreviewInput) -> Result<Box<dyn HistoryPreview>, String> {
264    Err("this scope is a declaration, not a preview".into())
265}
266
267/// One version of `R` as the read door answers it: the type's own `read`
268/// adapter over the frozen document, exactly as it answers the live record —
269/// its semantic content, never the raw record.
270///
271/// # Errors
272/// A [`HistoricalGap`] to show: the version does not read as the type, or the
273/// type has no read adapter.
274#[inline(never)]
275pub(crate) fn historical_content<R: Resource>(
276    frozen: &AutoCommit,
277    item: Option<&str>,
278) -> Result<serde_json::Value, HistoricalGap> {
279    if historical_support::<R>() == HistoricalSupport::Unsupported {
280        return Err(HistoricalGap::unsupported(format!(
281            "a {} shows no history of its content",
282            R::LABEL
283        )));
284    }
285    // The version must read as the app's type at all: one written by a
286    // newer app is incompatible, which is not the same as empty.
287    let state = R::read(frozen).map_err(|error| HistoricalGap::unavailable(error.to_string()))?;
288    // The read door's answer states the type's content as its `content`
289    // beside the version's heads, so the adapter's own JSON goes there
290    // unwrapped.
291    R::read_content(&state, frozen, item).map_err(HistoricalGap::unavailable)
292}
293
294fn preview<A: ResourceApp>(input: PreviewInput) -> Result<Box<dyn HistoryPreview>, String> {
295    preview_of(input, <A::Resource as Resource>::LABEL, &past_root::<A>)
296}
297
298/// [`preview`] of the app whose root over a past version `root` builds;
299/// `label` is its resource's [`Resource::LABEL`]. Not generic, so loading
300/// the version is compiled once rather than for every app.
301#[inline(never)]
302fn preview_of(
303    input: PreviewInput,
304    label: &'static str,
305    root: &dyn Fn(&AutoCommit) -> Result<ResourceRoot, String>,
306) -> Result<Box<dyn HistoryPreview>, String> {
307    let doc = AutoCommit::load(input.snapshot.bytes()).map_err(|error| error.to_string())?;
308    let root = root(&doc)?;
309    Ok(open_preview(root, doc, label, input.initial_view))
310}
311
312/// History's preview of `root` drawing `doc`, opened at `view`. Not generic
313/// over the app, so it and the preview's table are compiled once.
314fn open_preview(
315    root: ResourceRoot,
316    doc: AutoCommit,
317    label: &'static str,
318    view: PreviewView,
319) -> Box<dyn HistoryPreview> {
320    Box::new(past_preview(root, doc, label).with_view(view))
321}
322
323/// `root` drawing `doc`, the past version it was built over; `label` is
324/// the shown resource's [`Resource::LABEL`].
325fn past_preview(root: ResourceRoot, doc: AutoCommit, label: &'static str) -> FrozenRootPreview {
326    FrozenRootPreview::new(root.into_root_widget(), doc, label)
327}
328
329/// `A`'s root over `doc`, a frozen past version: read-only, keeping
330/// nothing of the person's.
331fn past_root<A: ResourceApp>(doc: &AutoCommit) -> Result<ResourceRoot, String> {
332    let reader = Reader::<A::Resource>::read(doc).map_err(|error| error.to_string())?;
333    Ok(build_root::<A>(
334        reader,
335        Editing::ReadOnly,
336        AppStorage::for_app(super::app::hooks::<A>().id()),
337        super::settings::Hosted::default(),
338    ))
339}
340
341/// A past version drawn by the app's own view, exactly as History mode's
342/// preview draws it: read-only, over the frozen document. Built by
343/// [`HeadlessApp::past_version`](super::testing::HeadlessApp::past_version).
344pub struct PastVersion<R: Resource> {
345    preview: FrozenRootPreview,
346    /// The root's live state, which the root widget holds erased.
347    live: Live<R>,
348}
349
350impl<R: Resource> PastVersion<R> {
351    /// Every name and value the drawn view exposes to assistive technology,
352    /// depth first: what a reader of the past version would hear.
353    pub fn texts(&self) -> Vec<String> {
354        ui_core::a11y::texts(self.preview.root())
355    }
356
357    /// The state the view was presented.
358    pub fn state(&self) -> std::rc::Rc<R::State> {
359        self.live.get()
360    }
361
362    /// The drawn size of the view.
363    pub fn size(&self) -> Rect {
364        self.preview.root().get_rect()
365    }
366}
367
368/// Lay out and draw `A`'s view over `doc`, a frozen past version, in a
369/// `size` pane.
370#[cfg(not(target_arch = "wasm32"))]
371pub(crate) fn render_past<A: ResourceApp>(
372    doc: AutoCommit,
373    size: (u32, u32),
374) -> Result<PastVersion<A::Resource>, String> {
375    let root = past_root::<A>(&doc)?;
376    let live = root
377        .typed::<A::Resource>()
378        .map(|typed| typed.live.clone())
379        .ok_or_else(|| format!("{} drew another resource", super::app::hooks::<A>().title()))?;
380    let mut preview = past_preview(root, doc, <A::Resource as Resource>::LABEL);
381    let theme = ui_core::Theme::dark();
382    let mut text = ui_core::text_render::TextRenderer::new();
383    let bounds = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1));
384    preview.layout(bounds, &mut text, &theme)?;
385    let mut scene = ui_core::vello::Scene::new();
386    preview.draw(
387        &mut scene,
388        ui_core::vello::kurbo::Affine::IDENTITY,
389        &mut text,
390        &theme,
391    );
392    Ok(PastVersion { preview, live })
393}
394
395/// Lay out `A`'s view over `doc`, a frozen past version, in `bounds` and
396/// draw it: what `render` answers for a version.
397#[cfg(not(target_family = "wasm"))]
398pub(crate) fn draw_past<A: ResourceApp>(
399    doc: AutoCommit,
400    bounds: Rect,
401    scene: &mut ui_core::vello::Scene,
402    transform: ui_core::vello::kurbo::Affine,
403    theme: &ui_core::Theme,
404) -> Result<(), String> {
405    use ui_core::Theme;
406    use ui_core::text_render::TextRenderer;
407    use ui_core::vello::Scene;
408    use ui_core::vello::kurbo::Affine;
409
410    // Lay out `preview` in `bounds` and draw it. An item of its own, not
411    // generic over `A`, so it is compiled once rather than in every app.
412    fn draw_preview(
413        mut preview: FrozenRootPreview,
414        bounds: Rect,
415        scene: &mut Scene,
416        transform: Affine,
417        theme: &Theme,
418    ) -> Result<(), String> {
419        let mut text = TextRenderer::new();
420        preview.layout(bounds, &mut text, theme)?;
421        preview.draw(scene, transform, &mut text, theme);
422        Ok(())
423    }
424    let root = past_root::<A>(&doc)?;
425    let preview = past_preview(root, doc, <A::Resource as Resource>::LABEL);
426    draw_preview(preview, bounds, scene, transform, theme)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::prelude::*;
433    use ui_core::Theme;
434    use ui_core::text_render::TextRenderer;
435    use ui_core::vello::Scene;
436    use ui_core::vello::kurbo::Affine;
437
438    #[derive(Model, Default)]
439    pub struct Page {
440        pub heading: String,
441    }
442
443    #[resource(kind = "test.page", label = "Page")]
444    impl Page {
445        /// Set the heading.
446        #[action]
447        pub fn set_heading(&mut self, heading: String) {
448            self.heading = heading;
449        }
450    }
451
452    struct PageApp;
453
454    impl ResourceApp for PageApp {
455        type Resource = Page;
456        const ID: &'static str = "so.tilo.test-page";
457        const TITLE: &'static str = "Page";
458
459        fn view(cx: &mut ViewCx<Page>) -> Element {
460            Box::new(Column::new().push(Box::new(
461                cx.text(|page| page.heading.clone()).with_id("heading"),
462            )))
463        }
464    }
465
466    #[derive(Model, Default, Debug, Clone, PartialEq)]
467    pub struct Shelf {
468        pub title: String,
469        pub books: Collection<Book>,
470        pub note: Option<String>,
471    }
472
473    #[derive(Model, Default, Debug, Clone, PartialEq)]
474    pub struct Book {
475        pub name: String,
476        pub read: bool,
477    }
478
479    #[resource(kind = "test.shelf", label = "Shelf")]
480    impl Shelf {
481        /// Rename the shelf.
482        #[action]
483        pub fn rename(&mut self, title: String) {
484            self.title = title;
485        }
486
487        /// Add a book.
488        #[action]
489        pub fn add(&mut self, cx: &mut ActionCx, name: String) {
490            self.books.push(cx.id(), Book { name, read: false });
491        }
492
493        /// Mark a book read.
494        #[action]
495        pub fn finish(&mut self, book: Id) {
496            if let Some(book) = self.books.get_mut(&book) {
497                book.read = true;
498            }
499        }
500
501        /// Remove a book.
502        #[action]
503        pub fn remove(&mut self, book: Id) {
504            self.books.remove(&book);
505        }
506
507        /// Annotate the shelf.
508        #[action]
509        pub fn annotate(&mut self, note: Option<String>) {
510            self.note = note;
511        }
512    }
513
514    fn restore(replica: &mut Replica<Shelf>, target: &[ui_core::automerge::ChangeHash]) {
515        use ui_core::history_mode::restore::{
516            RestoreOutcome, apply_prepared_content_restore, prepare_content_restore,
517        };
518        let schema = restore_schema::<Shelf>().expect("a record has a restore schema");
519        let mut doc = replica.doc().clone();
520        let current = doc.get_heads();
521        let plan = prepare_content_restore(&doc, schema, &current, target).unwrap();
522        assert_eq!(
523            apply_prepared_content_restore(&mut doc, &plan).unwrap(),
524            RestoreOutcome::Changed
525        );
526        doc.commit();
527        *replica = Replica::from_doc(doc);
528    }
529
530    #[test]
531    fn past_versions_restore_by_record_shape_and_render_read_only() {
532        // phase: a_record_restores_a_past_version_field_by_field_and_item_by_item
533        {
534            let mut shelf = Replica::<Shelf>::new();
535            shelf.apply(Shelf::RENAME.with("Reading")).unwrap();
536            shelf.apply(Shelf::ADD.with("Dune")).unwrap();
537            shelf.apply(Shelf::ADD.with("Emma")).unwrap();
538            let ids: Vec<Id> = shelf.state().unwrap().books.ids().cloned().collect();
539            let past_heads = shelf.heads();
540            let past = shelf.state().unwrap();
541
542            shelf.apply(Shelf::RENAME.with("Done")).unwrap();
543            shelf.apply(Shelf::FINISH.with(ids[0].clone())).unwrap();
544            shelf.apply(Shelf::REMOVE.with(ids[1].clone())).unwrap();
545            shelf.apply(Shelf::ADD.with("Ulysses")).unwrap();
546            shelf
547                .apply(Shelf::ANNOTATE.with(Some("tidy up".into())))
548                .unwrap();
549            assert_ne!(shelf.state().unwrap(), past);
550
551            restore(&mut shelf, &past_heads);
552            assert_eq!(shelf.state().unwrap(), past);
553            // Item identity survives: the restored books are the same items.
554            assert_eq!(
555                shelf
556                    .state()
557                    .unwrap()
558                    .books
559                    .ids()
560                    .cloned()
561                    .collect::<Vec<_>>(),
562                ids
563            );
564        }
565        // phase: a_resource_without_a_record_shape_has_no_restore_schema
566        {
567            struct Opaque;
568            impl Resource for Opaque {
569                const KIND: &'static str = "test.opaque";
570                const LABEL: &'static str = "Opaque";
571                type State = ();
572                type Action = <Shelf as Resource>::Action;
573                fn read(_: &tilo_resource::Doc) -> Result<(), tilo_resource::ModelError> {
574                    Ok(())
575                }
576            }
577            assert!(restore_schema::<Opaque>().is_none());
578            assert!(restore_schema::<Shelf>().is_some());
579        }
580        // phase: a_past_version_renders_through_the_apps_own_view_read_only
581        {
582            let mut page = Replica::<Page>::new();
583            page.apply(Page::SET_HEADING.with("First draft")).unwrap();
584            let past = page.heads();
585            page.apply(Page::SET_HEADING.with("Final")).unwrap();
586            let mut doc = page.doc().clone();
587            let bytes = doc.fork_at(&past).unwrap().save();
588
589            let frozen = AutoCommit::load(&bytes).unwrap();
590            let root = build_root::<PageApp>(
591                Reader::read(&frozen).unwrap(),
592                Editing::ReadOnly,
593                AppStorage::for_app(PageApp::ID),
594                crate::framework::settings::Hosted::default(),
595            );
596            let mut preview = past_preview(root, frozen, Page::LABEL);
597            let theme = Theme::dark();
598            let mut text = TextRenderer::new();
599            preview
600                .layout(Rect::new(10.0, 20.0, 410.0, 320.0), &mut text, &theme)
601                .unwrap();
602            let mut scene = Scene::new();
603            preview.draw(&mut scene, Affine::IDENTITY, &mut text, &theme);
604            assert_eq!(shown(preview.root()), ["First draft"]);
605            assert!(preview.root().get_rect().width() > 0.0);
606        }
607    }
608
609    fn shown(root: &dyn Widget) -> Vec<String> {
610        fn visit(widget: &dyn Widget, out: &mut Vec<String>) {
611            if let Some(node) = widget.accessibility()
612                && let Some(label) = node.name
613            {
614                out.push(label);
615            }
616            widget.for_each_child(&mut |child| visit(child, out));
617        }
618        let mut out = Vec::new();
619        visit(root, &mut out);
620        out
621    }
622}