1use 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
41pub(crate) fn file_history_witness<A: ResourceApp>() -> &'static HistoryDefinition {
44 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
80const FILE_SCOPE: &str = "file";
83
84pub(crate) fn history_definition<A: ResourceApp>() -> &'static HistoryDefinition {
86 leaked(TypeId::of::<HistoryKey<A>>(), || {
87 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
114fn 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
209fn 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
225fn 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
237pub struct PastVersion<R: Resource> {
241 preview: ViewPreview,
242 live: Live<R>,
244}
245
246impl<R: Resource> PastVersion<R> {
247 pub fn texts(&self) -> Vec<String> {
250 ui_core::a11y::texts(&self.preview.root)
251 }
252
253 pub fn state(&self) -> std::rc::Rc<R::State> {
255 self.live.get()
256 }
257
258 pub fn size(&self) -> Rect {
260 self.preview.root.get_rect()
261 }
262}
263
264#[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#[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 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 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 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 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 #[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 #[action]
478 pub fn rename(&mut self, title: String) {
479 self.title = title;
480 }
481
482 #[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 #[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 #[action]
498 pub fn remove(&mut self, book: Id) {
499 self.books.remove(&book);
500 }
501
502 #[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, ¤t, 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 {
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 assert_eq!(
550 shelf
551 .state()
552 .unwrap()
553 .books
554 .ids()
555 .cloned()
556 .collect::<Vec<_>>(),
557 ids
558 );
559 }
560 {
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 {
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}