1use 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
34pub(crate) fn file_history_witness<A: ResourceApp>() -> &'static HistoryDefinition {
37 let definition = history_definition::<A>();
39 leaked(TypeId::of::<FileWitnessKey<A>>(), || {
40 file_witness(definition)
41 })
42}
43
44#[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
80const FILE_SCOPE: &str = "file";
83
84pub(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#[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 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
127fn restore_schema<R: Resource>() -> Option<&'static HistoryRestoreSchema> {
135 restore_schema_of(R::shape(), R::KIND, R::VERSION, readable::<R>)
136}
137
138#[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
236fn readable<R: Resource>(doc: &AutoCommit) -> Result<(), String> {
238 R::read(doc).map(drop).map_err(|error| error.to_string())
239}
240
241#[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#[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 let state = R::read(frozen).map_err(|error| HistoricalGap::unavailable(error.to_string()))?;
288 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#[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
312fn 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
323fn past_preview(root: ResourceRoot, doc: AutoCommit, label: &'static str) -> FrozenRootPreview {
326 FrozenRootPreview::new(root.into_root_widget(), doc, label)
327}
328
329fn 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
341pub struct PastVersion<R: Resource> {
345 preview: FrozenRootPreview,
346 live: Live<R>,
348}
349
350impl<R: Resource> PastVersion<R> {
351 pub fn texts(&self) -> Vec<String> {
354 ui_core::a11y::texts(self.preview.root())
355 }
356
357 pub fn state(&self) -> std::rc::Rc<R::State> {
359 self.live.get()
360 }
361
362 pub fn size(&self) -> Rect {
364 self.preview.root().get_rect()
365 }
366}
367
368#[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#[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 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 #[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 #[action]
483 pub fn rename(&mut self, title: String) {
484 self.title = title;
485 }
486
487 #[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 #[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 #[action]
503 pub fn remove(&mut self, book: Id) {
504 self.books.remove(&book);
505 }
506
507 #[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, ¤t, 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 {
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 assert_eq!(
555 shelf
556 .state()
557 .unwrap()
558 .books
559 .ids()
560 .cloned()
561 .collect::<Vec<_>>(),
562 ids
563 );
564 }
565 {
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 {
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}