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