1//! The ops each stored change lowers to, recorded as the change applies so a page is never
2//! rebuilt to be saved.
3
4use super::*;
5use onestore::op;
6use onestore::{OutlineEdit, document::Layout};
7
8type Lowered = Result<Vec<PageOp>, onestore::Error>;
9
10fn refused(message: &'static str) -> onestore::Error {
11 onestore::Error { offset: 0, message }
12}
13
14fn stale(_: EditError) -> onestore::Error {
15 refused("The editor lost track of the stored page")
16}
17
18/// Whether `op` needs paragraph `blank`, last in `outline` and not stored yet: it edits the
19/// paragraph or its text, or adds to or moves within the outline's top level.
20fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool {
21 let text = blank.text().map(|text| text.id);
22 let own = |id: &ExGuid| *id == blank.id || Some(*id) == text;
23 match op {
24 PageOp::Text { text, .. }
25 | PageOp::Format { text, .. }
26 | PageOp::Link { text, .. }
27 | PageOp::Equation { text, .. }
28 | PageOp::Split { text, .. } => own(text),
29 PageOp::Join { left, right } => own(left) || own(right),
30 PageOp::Insert {
31 container, before, ..
32 } => *container == outline || before.as_ref().is_some_and(own),
33 PageOp::Move {
34 object,
35 parent,
36 before,
37 } => {
38 own(object)
39 || parent
40 .as_ref()
41 .is_some_and(|parent| *parent == outline || own(parent))
42 || before.as_ref().is_some_and(own)
43 }
44 PageOp::Delete { object: id }
45 | PageOp::Level { paragraph: id, .. }
46 | PageOp::Outline { object: id, .. }
47 | PageOp::Paragraph { paragraph: id, .. }
48 | PageOp::Style { paragraph: id, .. }
49 | PageOp::Unstyle { paragraph: id }
50 | PageOp::Media { paragraph: id, .. }
51 | PageOp::List { paragraph: id, .. }
52 | PageOp::Tags { target: id, .. } => own(id),
53 _ => false,
54 }
55}
56
57/// Definitions a paragraph names: its lists, style and note tags.
58pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + '_ {
59 let content_tags = match &node.content {
60 ParagraphContent::Text(text) => text.tags.as_slice(),
61 ParagraphContent::Table(table) => table.tags.as_slice(),
62 ParagraphContent::Image(image) => image.tags.as_slice(),
63 ParagraphContent::Attachment(file) => file.tags.as_slice(),
64 _ => &[],
65 };
66 node.lists.iter().copied().chain(node.style).chain(
67 node.tags
68 .iter()
69 .chain(content_tags)
70 .filter_map(|tag| tag.definition),
71 )
72}
73
74/// The definitions `paragraphs` and their cells name.
75pub(super) fn named(
76 definitions: &BTreeMap<ExGuid, Definition>,
77 paragraphs: &[&[PageParagraph]],
78) -> BTreeMap<ExGuid, Definition> {
79 paragraphs
80 .iter()
81 .flat_map(|list| descendants(list, None))
82 .flat_map(|(_, _, node)| references(node))
83 .filter_map(|id| Some((id, definitions.get(&id)?.clone())))
84 .collect()
85}
86
87/// `op::lower` of paragraphs `before` of `container` replaced by `nodes[range]` and the
88/// definitions they name, so it stays O(edit). When the structure changes, it also sees the
89/// unchanged paragraphs an op may split, join, move or anchor to: the ones either side and
90/// their ancestors, the range's descendants past it, and after them the next child of each
91/// ancestor.
92fn lower(
93 container: ExGuid,
94 nodes: &[PageParagraph],
95 range: Range<usize>,
96 before: &[PageParagraph],
97 definitions: &BTreeMap<ExGuid, Definition>,
98) -> Lowered {
99 let after = &nodes[range.clone()];
100 if before.is_empty() && after.is_empty() {
101 return Ok(Vec::new());
102 }
103 let mut context = BTreeSet::new();
104 let shape = |node: &PageParagraph| (node.id, node.parent, node.level);
105 if before.iter().map(shape).ne(after.iter().map(shape)) {
106 context.extend(range.start.checked_sub(1));
107 context.extend((range.end < nodes.len()).then_some(range.end));
108 let held: BTreeSet<ExGuid> = before.iter().chain(after).map(|node| node.id).collect();
109 let mut wanted: Vec<ExGuid> = before
110 .iter()
111 .chain(after)
112 .chain(context.iter().map(|&at| &nodes[at]))
113 .filter_map(|node| node.parent)
114 .collect();
115 let mut open = BTreeMap::from([(None, 0)]);
116 while let Some(id) = wanted.pop() {
117 if held.contains(&id) || context.iter().any(|&at| nodes[at].id == id) {
118 continue;
119 }
120 if let Some(at) = nodes[..range.start].iter().rposition(|node| node.id == id) {
121 context.insert(at);
122 open.insert(Some(id), nodes[at].level);
123 wanted.extend(nodes[at].parent);
124 }
125 }
126 // Descendants of the range move with the paragraphs it splits, joins and moves.
127 let mut members = held;
128 let mut end = range.end;
129 while let Some(node) = nodes.get(end)
130 && node.parent.is_some_and(|parent| members.contains(&parent))
131 {
132 members.insert(node.id);
133 context.insert(end);
134 end += 1;
135 }
136 for (at, node) in nodes.iter().enumerate().skip(end) {
137 if open.remove(&node.parent).is_some() {
138 context.insert(at);
139 }
140 // A container's children end where its subtree does.
141 open.retain(|_, level| *level < node.level);
142 if open.is_empty() {
143 break;
144 }
145 }
146 }
147 let around = |middle: &[PageParagraph]| {
148 let (head, tail): (Vec<usize>, Vec<usize>) =
149 context.iter().partition(|&&at| at < range.start);
150 head.iter()
151 .map(|&at| &nodes[at])
152 .chain(middle)
153 .chain(tail.iter().map(|&at| &nodes[at]))
154 .cloned()
155 .collect::<Vec<_>>()
156 };
157 let (before, after) = (around(before), around(after));
158 let named = named(definitions, &[&before, &after]);
159 // What `op::lower` cannot move or change in place is removed and inserted again under
160 // the identities it had: stored paragraphs a new table cell holds, and, where lowering
161 // fails, a paragraph whose text object a join replaced outside what `Join` expresses
162 // (between empty paragraphs, or back on undo) with the paragraph whose text it takes.
163 let text = |node: &PageParagraph| node.text().map(|text| text.id);
164 let stored: BTreeMap<ExGuid, Option<ExGuid>> = descendants(&before, None)
165 .map(|(_, _, node)| (node.id, text(node)))
166 .collect();
167 let cells: BTreeSet<ExGuid> = descendants(&before, None)
168 .filter_map(|(cell, _, _)| cell)
169 .collect();
170 let mut removed: BTreeSet<ExGuid> = descendants(&after, None)
171 .filter(|(cell, _, node)| {
172 stored.contains_key(&node.id) && cell.is_some_and(|cell| !cells.contains(&cell))
173 })
174 .map(|(_, _, node)| node.id)
175 .collect();
176 let lowered = |removed: &BTreeSet<ExGuid>| {
177 let mut ops = Vec::new();
178 let mut deleted = Vec::new();
179 let mut kept = without(&before, removed, &mut ops, &mut deleted).map_err(stale)?;
180 if kept.is_empty() {
181 let anchor = anchor().map_err(stale)?;
182 ops.push(PageOp::Insert {
183 container,
184 before: None,
185 paragraphs: vec![anchor.clone()],
186 });
187 kept.push(anchor);
188 }
189 ops.extend(deleted.into_iter().map(|object| PageOp::Delete { object }));
190 ops.extend(op::lower(container, &kept, &after, None, &named)?);
191 Ok(ops)
192 };
193 let error = match lowered(&removed) {
194 Ok(ops) => return Ok(ops),
195 Err(error) => error,
196 };
197 let taken: BTreeSet<ExGuid> = descendants(&after, None)
198 .filter(|(_, _, node)| stored.get(&node.id).is_some_and(|old| *old != text(node)))
199 .filter_map(|(_, _, node)| {
200 removed.insert(node.id);
201 text(node)
202 })
203 .collect();
204 if taken.is_empty() {
205 return Err(error);
206 }
207 removed.extend(
208 descendants(&before, None)
209 .filter(|(_, _, node)| text(node).is_some_and(|id| taken.contains(&id)))
210 .map(|(_, _, node)| node.id),
211 );
212 lowered(&removed)
213}
214
215/// An empty paragraph keeping a container from emptying while its paragraphs are removed
216/// and inserted again; the lowering removes it after.
217pub(super) fn anchor() -> Result<PageParagraph, EditError> {
218 crate::document::node(
219 Paragraph::new(String::new(), Format::default()),
220 Format::default(),
221 )
222}
223
224/// `nodes` without the subtrees of `removed`, whose roots `deleted` receives; a table cell
225/// they would empty first receives an anchor, which `ops` inserts.
226fn without(
227 nodes: &[PageParagraph],
228 removed: &BTreeSet<ExGuid>,
229 ops: &mut Vec<PageOp>,
230 deleted: &mut Vec<ExGuid>,
231) -> Result<Vec<PageParagraph>, EditError> {
232 let mut gone = BTreeSet::new();
233 let mut kept = Vec::new();
234 for node in nodes {
235 if node.parent.is_some_and(|parent| gone.contains(&parent)) {
236 gone.insert(node.id);
237 } else if removed.contains(&node.id) {
238 gone.insert(node.id);
239 deleted.push(node.id);
240 } else {
241 let mut node = node.clone();
242 if let ParagraphContent::Table(table) = &mut node.content {
243 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
244 cell.paragraphs = without(&cell.paragraphs, removed, ops, deleted)?;
245 if cell.paragraphs.is_empty() {
246 let anchor = anchor()?;
247 ops.push(PageOp::Insert {
248 container: cell.id,
249 before: None,
250 paragraphs: vec![anchor.clone()],
251 });
252 cell.paragraphs.push(anchor);
253 }
254 }
255 }
256 kept.push(node);
257 }
258 }
259 Ok(kept)
260}
261
262/// A page-level outline's position and width, as `op::lower_page` compares them.
263pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {
264 let mut ops = Vec::new();
265 if (new.x, new.y) != (old.x, old.y) {
266 let (Some(x), Some(y)) = (new.x, new.y) else {
267 return Err(refused("An outline position needs both coordinates"));
268 };
269 ops.push(PageOp::Outline {
270 object: id,
271 edit: OutlineEdit::Position { x, y },
272 });
273 }
274 if (new.max_width, new.width_set_by_user) != (old.max_width, old.width_set_by_user) {
275 ops.push(PageOp::Outline {
276 object: id,
277 edit: OutlineEdit::Width {
278 points: new
279 .max_width
280 .ok_or_else(|| refused("An outline width cannot be removed"))?,
281 user_set: new.width_set_by_user == Some(true),
282 },
283 });
284 }
285 Ok(ops)
286}
287
288/// The op giving `target` `tags`, with the definitions they name.
289pub(super) fn tags(
290 target: ExGuid,
291 tags: Vec<onestore::document::Tag>,
292 definitions: &BTreeMap<ExGuid, onestore::page::Definition>,
293) -> Vec<PageOp> {
294 let definitions = tags
295 .iter()
296 .filter_map(|tag| tag.definition)
297 .collect::<BTreeSet<_>>()
298 .into_iter()
299 .filter_map(|id| Some((id, definitions.get(&id)?.clone())))
300 .collect();
301 vec![PageOp::Tags {
302 target,
303 tags,
304 definitions,
305 }]
306}
307
308/// A page picture's stored position, size and description.
309pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {
310 vec![PageOp::Picture {
311 picture: image.id,
312 layout: image.layout.clone(),
313 alt: image.alt.clone(),
314 }]
315}
316
317impl CanvasEditor {
318 /// The ops the stored page took since the last call, in order; an error means an edit
319 /// since then cannot be stored, and the page should be reopened from storage.
320 pub fn take_ops(&mut self) -> Result<Vec<PageOp>, onestore::Error> {
321 let taken = std::mem::replace(&mut self.ops, Ok(Vec::new()));
322 match (&taken, &mut self.stored) {
323 (Ok(ops), Some((_, sent))) => sent.extend(ops.iter().cloned()),
324 (Err(_), stored) => *stored = None,
325 (Ok(_), None) => {}
326 }
327 taken
328 }
329
330 /// Queues `lowered`, storing first the provisional paragraph of an outline an op reaches.
331 pub(super) fn record(&mut self, lowered: Lowered) {
332 let Ok(ops) = &mut self.ops else {
333 return;
334 };
335 let lowered = match lowered {
336 Ok(lowered) => lowered,
337 Err(error) => {
338 self.ops = Err(error);
339 return;
340 }
341 };
342 for op in lowered {
343 for (outline, (blank, stored)) in &mut self.provisional {
344 if !*stored && reaches(&op, *outline, blank) {
345 *stored = true;
346 ops.push(PageOp::Insert {
347 container: *outline,
348 before: None,
349 paragraphs: vec![blank.clone()],
350 });
351 }
352 }
353 ops.push(op);
354 }
355 }
356
357 /// Ops for `change`, the inverse of an edit just applied to stored outline `outline`.
358 pub(super) fn change_ops(&self, outline: &TextOutline, change: &TextChange) -> Lowered {
359 let edit = &change.edit;
360 let nodes = outline.document.container(edit.container).map_err(stale)?;
361 let mut ops = lower(
362 edit.container.unwrap_or(outline.id),
363 nodes,
364 edit.range.clone(),
365 &edit.replacement,
366 &self.definitions,
367 )?;
368 for (_, _, node) in descendants(outline.document.nodes(), None) {
369 if let ParagraphContent::Table(table) = &node.content
370 && edit.columns.contains_key(&table.id)
371 {
372 ops.push(PageOp::Table {
373 table: table.id,
374 edit: op::TableEdit::Columns(table.columns.clone()),
375 });
376 }
377 }
378 for placement in &change.positions {
379 ops.extend(self.placement_ops(placement)?);
380 }
381 Ok(ops)
382 }
383
384 /// A page object's move from `placement`, as title flow makes it.
385 pub(super) fn placement_ops(&self, placement: &Placement) -> Lowered {
386 let old = Layout {
387 x: placement.position[0],
388 y: placement.position[1],
389 ..Default::default()
390 };
391 let moved = |layout: &Layout| Layout {
392 x: layout.x,
393 y: layout.y,
394 ..Default::default()
395 };
396 let picture = |image: &onestore::page::Image| {
397 Ok(if [image.layout.x, image.layout.y] == placement.position {
398 Vec::new()
399 } else {
400 picture_layout(image)
401 })
402 };
403 if let Some(outline) = self.outlines.iter().find(|item| item.id == placement.id) {
404 return layout_ops(outline.id, &old, &moved(&outline.layout));
405 }
406 for object in &self.objects {
407 if let Some(image) = object.picture().filter(|image| image.id == placement.id) {
408 return picture(image);
409 }
410 match (object, object.layout()) {
411 (page::Content::ReadOnly(object), Some((id, _)))
412 if id == placement.id && !matches!(object.source, PageObject::Outline(_)) =>
413 {
414 return Err(refused("Unsupported objects cannot be moved"));
415 }
416 (_, Some((id, layout))) if id == placement.id => {
417 return layout_ops(id, &old, &moved(layout));
418 }
419 _ => {}
420 }
421 }
422 Err(refused("The editor lost track of the stored page"))
423 }
424
425 /// Ops turning stored outline `old` into what `outlines` holds under `id` now: added,
426 /// removed, or its changed paragraphs and geometry.
427 pub(super) fn outline_ops(&self, id: ExGuid, old: Option<&TextOutline>) -> Lowered {
428 let new = self.outlines.iter().find(|outline| outline.id == id);
429 match (old, new) {
430 (None, None) => Ok(Vec::new()),
431 (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]),
432 (None, Some(new)) => self.added(new.snapshot()),
433 (Some(old), Some(new)) => {
434 let (a, b) = (old.document.nodes(), new.document.nodes());
435 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
436 let suffix = a[prefix..]
437 .iter()
438 .rev()
439 .zip(b[prefix..].iter().rev())
440 .take_while(|(x, y)| x == y)
441 .count();
442 let mut ops = lower(
443 id,
444 b,
445 prefix..b.len() - suffix,
446 &a[prefix..a.len() - suffix],
447 &self.definitions,
448 )?;
449 ops.extend(layout_ops(id, &old.layout, &new.layout)?);
450 Ok(ops)
451 }
452 }
453 }
454
455 /// Ops adding `outline`, new to the stored page, in its place in paint order.
456 pub(super) fn added(&self, outline: Outline) -> Lowered {
457 let page = |objects| Page {
458 title: String::new(),
459 identity: None,
460 created: None,
461 margin_origin: [0.0; 2],
462 rtl: false,
463 color: None,
464 rule_lines: None,
465 objects,
466 definitions: named(&self.definitions, &[&outline.paragraphs]),
467 };
468 let mut ops = op::lower_page(
469 &page(Vec::new()),
470 &page(vec![PageObject::Outline(outline.clone())]),
471 )?;
472 let successor = self.successor(outline.id);
473 for op in &mut ops {
474 if let PageOp::Add { before, .. } = op {
475 *before = successor;
476 }
477 }
478 Ok(ops)
479 }
480
481 /// The page's date as the editor shows it.
482 pub(super) fn date_ops(&self) -> Vec<PageOp> {
483 let Some(date) = &self.date else {
484 return Vec::new();
485 };
486 vec![PageOp::Date {
487 created: date.timestamp(),
488 fields: date
489 .fields()
490 .filter_map(|(_, paragraph)| {
491 let text = paragraph.text()?;
492 Some((text.id, text.text.text().to_owned()))
493 })
494 .collect(),
495 }]
496 }
497
498 /// The page child `page()` places after `id`, titles aside.
499 pub(super) fn successor(&self, id: ExGuid) -> Option<ExGuid> {
500 let mut children: Vec<ExGuid> = Vec::new();
501 for content in &self.objects {
502 let child = match content {
503 page::Content::Editable(outline)
504 if self.outlines.iter().any(|o| o.id == *outline) =>
505 {
506 *outline
507 }
508 page::Content::Editable(_) => continue,
509 page::Content::Outline { source, .. } => source.id,
510 page::Content::Date { .. } => continue,
511 page::Content::Image(image) => image.id,
512 page::Content::File { source, .. } => source.id,
513 page::Content::Ink(ink) => ink.id,
514 page::Content::ReadOnly(object)
515 if matches!(object.source, PageObject::Title(_)) =>
516 {
517 continue;
518 }
519 page::Content::ReadOnly(object) => object.source.id(),
520 };
521 // Titles, the date's among them, are listed apart from what an op places.
522 if !self
523 .header
524 .areas
525 .iter()
526 .any(|area| area.origins.contains_key(&child))
527 {
528 children.push(child);
529 }
530 }
531 children.extend(
532 self.outlines
533 .iter()
534 .filter(|outline| !self.has_page_outline(outline.id))
535 .map(|outline| outline.id),
536 );
537 let at = children.iter().position(|child| *child == id)?;
538 children.get(at + 1).copied()
539 }
540}
541
542/// `image` with the ops `editor` recorded since they were last taken applied to the page in
543/// `space` and sealed, as the application saves an edit.
544#[cfg(test)]
545pub(crate) fn saved(image: &[u8], space: ExGuid, editor: &mut CanvasEditor) -> Vec<u8> {
546 let arena = onestore::Arena::default();
547 let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
548 let ops = editor.take_ops().unwrap();
549 let ops = ops
550 .into_iter()
551 .map(|op| op::Op::Page { space, op })
552 .collect();
553 let edit = op::Edit {
554 at: 134_000_000_000_000_000,
555 ops,
556 };
557 section.apply("Author", &edit).unwrap();
558 section.seal().unwrap();
559 section.image()
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::layout::TextEngine;
566 use std::time::{Duration, Instant};
567
568 fn median(mut samples: Vec<Duration>) -> Duration {
569 samples.sort();
570 samples[samples.len() / 2]
571 }
572
573 /// The rows of the data-layer plan's table: each edit and its undo as ops, the undo
574 /// of a join splitting the paragraph back under its own identities.
575 #[test]
576 fn edits_and_their_undo_record_the_plan_s_ops() {
577 let mut engine = TextEngine::default();
578 let lines = ["Hello", "World"].map(|line| Paragraph::new(line.into(), Format::default()));
579 let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
580 let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
581 let nodes = editor.outlines()[0].document().nodes().to_vec();
582 let text = |index: usize| nodes[index].text().unwrap().id;
583 let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
584 let undone = |editor: &mut CanvasEditor, engine: &mut TextEngine| {
585 editor.undo(engine).unwrap();
586 editor.take_ops().unwrap()
587 };
588
589 editor.select(at(0, 3)).unwrap();
590 editor.insert(&mut engine, "ab").unwrap();
591 let typed = PageOp::Text {
592 text: text(0),
593 range: 3..3,
594 with: "ab".into(),
595 };
596 assert_eq!(editor.take_ops().unwrap(), [typed]);
597 let erased = PageOp::Text {
598 text: text(0),
599 range: 3..5,
600 with: String::new(),
601 };
602 assert_eq!(undone(&mut editor, &mut engine), [erased]);
603
604 editor.select(at(1, 0)).unwrap();
605 editor.delete(&mut engine, true).unwrap();
606 let joined = PageOp::Join {
607 left: text(0),
608 right: text(1),
609 };
610 assert_eq!(editor.take_ops().unwrap(), [joined]);
611 let split = PageOp::Split {
612 text: text(0),
613 at: 5,
614 paragraph: nodes[1].id,
615 right: text(1),
616 lists: Vec::new(),
617 };
618 assert_eq!(undone(&mut editor, &mut engine), [split]);
619
620 editor.select(at(1, 0)).unwrap();
621 editor.tab(&mut engine, false).unwrap();
622 let [PageOp::Move { object, parent, .. }] = &editor.take_ops().unwrap()[..] else {
623 panic!("Tab moves the paragraph under its sibling")
624 };
625 assert_eq!((*object, *parent), (nodes[1].id, Some(nodes[0].id)));
626 // A moved paragraph keeps its level where it lies deeper than its new parent.
627 let ops = undone(&mut editor, &mut engine);
628 let [
629 PageOp::Move { object, parent, .. },
630 PageOp::Level {
631 paragraph,
632 level: 1,
633 },
634 ] = &ops[..]
635 else {
636 panic!("undoing Tab moves it back and outdents it: {ops:?}")
637 };
638 assert_eq!(
639 (*object, *parent, *paragraph),
640 (nodes[1].id, Some(editor.outlines()[0].id), nodes[1].id)
641 );
642
643 editor
644 .select(
645 [
646 TextPosition {
647 paragraph: 0,
648 offset: 1,
649 },
650 TextPosition {
651 paragraph: 0,
652 offset: 4,
653 },
654 ]
655 .into(),
656 )
657 .unwrap();
658 editor
659 .format(&mut engine, Formatting::Toggle(format::Toggle::Bold))
660 .unwrap();
661 let bold = PageOp::Format {
662 text: text(0),
663 range: 1..4,
664 set: vec![onestore::TextAttribute::Bold(true)],
665 clear: Vec::new(),
666 };
667 assert_eq!(editor.take_ops().unwrap(), [bold]);
668 let [
669 PageOp::Format {
670 text: target,
671 range,
672 ..
673 },
674 ] = &undone(&mut editor, &mut engine)[..]
675 else {
676 panic!("undoing bold formats the range back")
677 };
678 assert_eq!((*target, range.clone()), (text(0), 1..4));
679 }
680
681 /// Alt+Shift+Up and Down swap a paragraph, children and all, with its sibling as moves,
682 /// keeping the caret on the moved text, and stop at the first and last sibling.
683 #[test]
684 fn moving_paragraphs_swaps_sibling_subtrees_as_move_ops() {
685 let mut engine = TextEngine::default();
686 let lines = ["A", "a", "B"].map(|line| Paragraph::new(line.into(), Format::default()));
687 let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
688 let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
689 editor
690 .select(
691 [TextPosition {
692 paragraph: 1,
693 offset: 0,
694 }; 2]
695 .into(),
696 )
697 .unwrap();
698 editor.indent(&mut engine, false).unwrap();
699 editor.take_ops().unwrap();
700 let texts = |editor: &CanvasEditor| {
701 editor.outlines()[0]
702 .document()
703 .nodes()
704 .iter()
705 .map(|node| (node.text().unwrap().text.text().to_owned(), node.level))
706 .collect::<Vec<_>>()
707 };
708 let caret = TextPosition {
709 paragraph: 2,
710 offset: 1,
711 };
712 editor.select([caret; 2].into()).unwrap();
713 assert!(!editor.move_paragraphs(&mut engine, false).unwrap());
714 assert!(editor.move_paragraphs(&mut engine, true).unwrap());
715 let moved = [("B".into(), 1), ("A".into(), 1), ("a".into(), 2)];
716 assert_eq!(texts(&editor), moved);
717 assert_eq!(
718 editor.selection().positions,
719 [TextPosition {
720 paragraph: 0,
721 offset: 1
722 }; 2]
723 );
724 let ops = editor.take_ops().unwrap();
725 assert!(
726 !ops.is_empty() && ops.iter().all(|op| matches!(op, PageOp::Move { .. })),
727 "{ops:?}"
728 );
729 assert!(!editor.move_paragraphs(&mut engine, true).unwrap());
730 assert!(editor.move_paragraphs(&mut engine, false).unwrap());
731 assert_eq!(texts(&editor)[2], ("B".into(), 1));
732 assert_eq!(editor.selection().positions[0].paragraph, 2);
733 editor.undo(&mut engine).unwrap();
734 assert_eq!(texts(&editor), moved);
735 }
736
737 /// A change from elsewhere reaches only what it changed: a refresh whose stored page is
738 /// what the editor's ops left (though storage normalized it) changes nothing, history
739 /// included; one that changes an outline drops that outline's history and keeps the
740 /// rest, which still undoes into the section.
741 #[test]
742 fn a_refresh_keeps_what_the_change_did_not_reach() {
743 use onestore::op::{Edit, Op};
744 let image =
745 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one");
746 let arena = onestore::Arena::default();
747 let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
748 let space = section.pages().unwrap()[0].0;
749 let mut engine = TextEngine::default();
750 let mut editor =
751 CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
752 let mut at = 133_000_000_000_000_000;
753 let mut store = |section: &mut onestore::Section<'_>, ops: Vec<PageOp>| {
754 at += 10_000_000;
755 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
756 section.apply("Test", &Edit { at, ops }).unwrap();
757 };
758 let local = editor.outlines().iter().find(|o| !o.title).unwrap().id;
759 editor.focus_outline(local).unwrap();
760 editor
761 .select(
762 [TextPosition {
763 paragraph: 0,
764 offset: 0,
765 }; 2]
766 .into(),
767 )
768 .unwrap();
769 editor.insert(&mut engine, "Local ").unwrap();
770 let created = editor
771 .create_outline(&mut engine, [400.0, 600.0], 300.0)
772 .unwrap();
773 editor.insert(&mut engine, "Mine").unwrap();
774 store(&mut section, editor.take_ops().unwrap());
775
776 let (shown, history) = (editor.page().unwrap(), editor.undo.len());
777 let stored = section.page(space).unwrap();
778 assert_ne!(
779 Page {
780 title: shown.title.clone(),
781 ..stored.clone()
782 },
783 shown,
784 "storage normalizes"
785 );
786 assert!(!editor.refresh(stored, &mut engine).unwrap());
787 assert_eq!(
788 (
789 editor.page().unwrap(),
790 editor.undo.len(),
791 editor.active_outline().id
792 ),
793 (shown, history, created)
794 );
795
796 let text = section
797 .page(space)
798 .unwrap()
799 .objects
800 .iter()
801 .find_map(|object| match object {
802 onestore::page::PageObject::Outline(outline) if outline.id == local => outline
803 .paragraphs
804 .last()?
805 .text()
806 .map(|text| (text.id, text.text.text().encode_utf16().count() as u32)),
807 _ => None,
808 });
809 let (text, length) = text.unwrap();
810 store(
811 &mut section,
812 vec![PageOp::Text {
813 text,
814 range: length..length,
815 with: " remote".into(),
816 }],
817 );
818 assert!(
819 editor
820 .refresh(section.page(space).unwrap(), &mut engine)
821 .unwrap()
822 );
823 assert_eq!(
824 editor.undo.len(),
825 history - 1,
826 "the typing in the changed outline goes"
827 );
828 assert_eq!(editor.active_outline().id, created);
829 assert!(editor.undo(&mut engine).unwrap());
830 assert!(editor.undo(&mut engine).unwrap());
831 store(&mut section, editor.take_ops().unwrap());
832 assert!(!editor.undo(&mut engine).unwrap());
833 let page = section.page(space).unwrap();
834 assert!(!page.objects.iter().any(|object| object.id() == created));
835 let texts: Vec<String> = editor
836 .outlines()
837 .iter()
838 .flat_map(|o| o.document().paragraphs().map(|p| p.text().to_owned()))
839 .collect();
840 assert!(texts.iter().any(|text| text.starts_with("Local ")));
841 assert!(texts.iter().any(|text| text.ends_with(" remote")));
842 }
843
844 /// One keystroke on the 3000-paragraph probe page, on the frame thread:
845 /// `SECTION_PROBE=path cargo test --release -p canvas --lib keystroke_recording -- --ignored --nocapture`.
846 #[test]
847 #[ignore]
848 fn keystroke_recording() {
849 let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into());
850 let image = std::fs::read(&path).unwrap();
851 let arena = onestore::Arena::default();
852 let mut section = onestore::Section::open(&arena, image).unwrap();
853 let page = section
854 .pages()
855 .unwrap()
856 .into_iter()
857 .map(|(space, ..)| section.page(space).unwrap())
858 .max_by_key(|page| format!("{page:?}").len())
859 .unwrap();
860 let mut engine = TextEngine::default();
861 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
862 let outline = editor
863 .outlines()
864 .iter()
865 .max_by_key(|outline| outline.document().nodes().len())
866 .unwrap();
867 let (id, count) = (outline.id, outline.document().nodes().len());
868 editor.focus_outline(id).unwrap();
869 let caret = TextPosition {
870 paragraph: count / 2,
871 offset: 0,
872 };
873 editor.select([caret; 2].into()).unwrap();
874 let (mut typing, mut recording, mut rebuilding) = (Vec::new(), Vec::new(), Vec::new());
875 let mut previous = editor.page().unwrap();
876 for _ in 0..200 {
877 let start = Instant::now();
878 editor.insert(&mut engine, "x").unwrap();
879 typing.push(start.elapsed());
880 let Some(History::Text { change, .. }) = editor.undo.last() else {
881 unreachable!()
882 };
883 let start = Instant::now();
884 let ops = editor.change_ops(editor.active_outline(), change).unwrap();
885 recording.push(start.elapsed());
886 assert_eq!(ops.len(), 1, "{ops:?}");
887 assert_eq!(editor.take_ops().unwrap().len(), 1);
888 let start = Instant::now();
889 let page = editor.page().unwrap();
890 assert!(page != previous);
891 rebuilding.push(start.elapsed());
892 previous = page;
893 }
894 println!(
895 "{path}: {count} paragraphs; keystroke {:?}, of which recording its op {:?}; \
896 the page rebuilt and compared as persist did {:?}",
897 median(typing),
898 median(recording),
899 median(rebuilding)
900 );
901 }
902}