Skip to main content

odox_ui/
flow_model.rs

1//! The paragraphs under a flow's root, as the page editor sees them.
2//!
3//! A paragraph is named by its path of child indices from the root the flow
4//! draws, which is the path [`crate::Flow`] reports it under. The editor's
5//! edits become `odox-core` edits on the tree, and an edit that begins an undo
6//! step is recorded in [`Editing`] once it has succeeded. A format writes
7//! automatic styles, which the document's [`Styles`] is told of as they are
8//! written. DESIGN.md ยง11.
9//
10// Author: David M. Anderson
11// Built with AI assistance (Claude, Anthropic)
12
13use std::cell::OnceCell;
14use std::ops::Range;
15
16use eframe::egui::Id;
17use egui_richedit::{Edit, Fragment, Mark, Model, Position, RichEdit, Selection};
18use odox_core::edit::{self, is_paragraph};
19use odox_core::{Element, ListKind, Ns, Refused, Styles};
20
21use crate::Editing;
22use crate::find::{Match, Replaced};
23use odox_core::edit::is_block_container;
24
25/// The editor over a flow's paragraphs.
26pub type PageEditor = RichEdit<Vec<usize>>;
27
28/// The id the page editor takes the keyboard under. The shell reads it to
29/// know that typing on the page still leaves its shortcuts to it.
30pub(crate) fn page_editor_id() -> Id {
31    Id::new("odox-page-editor")
32}
33
34/// A page editor with nothing selected.
35pub fn page_editor() -> PageEditor {
36    RichEdit::new(page_editor_id())
37}
38
39/// What a button for a kind of paragraph asks for.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Block {
42    /// A heading of a level, or body text again where the paragraphs already
43    /// are one.
44    Heading(u8),
45    /// Body text.
46    Body,
47    /// A bulleted list, or no list where the paragraphs already are one.
48    Bullets,
49    /// A numbered list, or no list where the paragraphs already are one.
50    Numbers,
51}
52
53/// The paragraphs under a root in a content tree, for one frame's editing.
54pub struct FlowModel<'a> {
55    content: &'a mut Element,
56    styles: &'a mut Styles,
57    /// Where the flow's root is under the content root.
58    root: Vec<usize>,
59    editing: &'a mut Editing,
60    /// The part of the root the paragraphs are kept to, as a path under it:
61    /// on a slide, the one shape being typed into.
62    scope: Vec<usize>,
63    /// Put in front of every path an undo step remembers, for a view whose
64    /// paths mean something only with it: on a deck, which slide.
65    tag: Vec<usize>,
66    /// Every editable paragraph in drawing order, found when first asked for
67    /// and forgotten when an edit changes it.
68    order: OnceCell<Vec<Vec<usize>>>,
69}
70
71impl<'a> FlowModel<'a> {
72    /// The paragraphs under the element at a path in a content tree, whose
73    /// document's styles are `styles`.
74    pub fn new(
75        content: &'a mut Element,
76        styles: &'a mut Styles,
77        root: Vec<usize>,
78        editing: &'a mut Editing,
79    ) -> Self {
80        Self {
81            content,
82            styles,
83            root,
84            editing,
85            scope: Vec::new(),
86            tag: Vec::new(),
87            order: OnceCell::new(),
88        }
89    }
90
91    /// Have the caret an undo step remembers begin with these indices, which
92    /// the view takes off again when it is asked to put the caret back.
93    #[must_use]
94    pub fn tagged(mut self, tag: Vec<usize>) -> Self {
95        self.tag = tag;
96        self
97    }
98
99    /// Keep to the paragraphs under one element beneath the root, so that
100    /// moving and joining never leave it: a slide's label does not run on
101    /// into the next shape.
102    #[must_use]
103    pub fn within(mut self, scope: Vec<usize>) -> Self {
104        self.scope = scope;
105        self
106    }
107
108    fn root(&self) -> Option<&Element> {
109        self.content.at(&self.root)
110    }
111
112    fn paragraph_mut(&mut self, path: &[usize]) -> Option<&mut Element> {
113        self.content
114            .at_mut(&self.root)?
115            .at_mut(path)
116            .filter(|element| is_paragraph(element))
117    }
118
119    fn order(&self) -> &[Vec<usize>] {
120        self.order.get_or_init(|| {
121            let mut out = self.root().map(paragraph_paths).unwrap_or_default();
122            out.retain(|path| path.starts_with(&self.scope));
123            out
124        })
125    }
126
127    fn neighbour(&self, paragraph: &[usize], step: isize) -> Option<Vec<usize>> {
128        let order = self.order();
129        let index = order.iter().position(|p| p == paragraph)?;
130        order.get(index.checked_add_signed(step)?).cloned()
131    }
132
133    fn replace(
134        &mut self,
135        from: &Position<Vec<usize>>,
136        to: &Position<Vec<usize>>,
137        text: &str,
138    ) -> Option<()> {
139        let root = self.content.at_mut(&self.root)?;
140        match edit::replace_range(
141            root,
142            (&from.paragraph, from.offset),
143            (&to.paragraph, to.offset),
144            text,
145        ) {
146            Ok(()) => Some(()),
147            Err(Refused::Structure) => self.clear_between(from, to, text),
148            Err(_) => None,
149        }
150    }
151
152    /// A range that crosses a table or a frame, which no join takes apart:
153    /// the selected text goes from every paragraph it covers, each paragraph
154    /// stays where it is, and the new text goes in at the start. Nothing to
155    /// remove and nothing to put in, as Backspace at the start of a cell, is
156    /// refused rather than made an edit of nothing.
157    fn clear_between(
158        &mut self,
159        from: &Position<Vec<usize>>,
160        to: &Position<Vec<usize>>,
161        text: &str,
162    ) -> Option<()> {
163        let ranges = self.covered(from, to)?;
164        if text.is_empty() && ranges.iter().all(|(_, range)| range.is_empty()) {
165            return None;
166        }
167        for (path, range) in ranges.into_iter().rev() {
168            let with = if path == from.paragraph { text } else { "" };
169            let paragraph = self.paragraph_mut(&path)?;
170            edit::replace(paragraph, range, with);
171        }
172        Some(())
173    }
174
175    /// Each paragraph from one position to another in drawing order, with
176    /// the range of its text between them.
177    fn covered(
178        &self,
179        from: &Position<Vec<usize>>,
180        to: &Position<Vec<usize>>,
181    ) -> Option<Vec<(Vec<usize>, Range<usize>)>> {
182        let order = self.order();
183        let first = order.iter().position(|p| *p == from.paragraph)?;
184        let last = order.iter().position(|p| *p == to.paragraph)?;
185        let covered: Vec<Vec<usize>> = order.get(first..=last)?.to_vec();
186        Some(
187            covered
188                .into_iter()
189                .map(|path| {
190                    let len = self.text(&path).map_or(0, |t| t.chars().count());
191                    let start = if path == from.paragraph {
192                        from.offset
193                    } else {
194                        0
195                    };
196                    let end = if path == to.paragraph { to.offset } else { len };
197                    (path, start..end.max(start))
198                })
199                .collect(),
200        )
201    }
202
203    /// The paragraphs a selection runs over, in the order they are drawn.
204    fn selected_paragraphs(&self, selection: &Selection<Vec<usize>>) -> Option<Vec<Vec<usize>>> {
205        let order = self.order();
206        let anchor = order
207            .iter()
208            .position(|p| *p == selection.anchor.paragraph)?;
209        let focus = order.iter().position(|p| *p == selection.focus.paragraph)?;
210        Some(order[anchor.min(focus)..=anchor.max(focus)].to_vec())
211    }
212
213    /// Whether a block is what every paragraph a selection runs over already
214    /// is, which is when its button is lit and pressing it takes it off.
215    pub fn is_block(&self, selection: &Selection<Vec<usize>>, block: Block) -> bool {
216        let Some(paragraphs) = self.selected_paragraphs(selection) else {
217            return false;
218        };
219        paragraphs.iter().all(|paragraph| {
220            let path = [self.root.as_slice(), paragraph].concat();
221            let (heading, list) = edit::block_state(self.content, &path, self.styles);
222            match block {
223                Block::Heading(level) => heading == Some(level),
224                Block::Body => heading.is_none(),
225                Block::Bullets => list == Some(ListKind::Bullet),
226                Block::Numbers => list == Some(ListKind::Number),
227            }
228        })
229    }
230
231    /// Replace each match of a search with a text, last first so that the
232    /// offsets of the ones before hold. The first replacement begins an undo
233    /// step when `new_step` says so and the rest join it. Answers how many
234    /// were replaced.
235    pub fn replace_matches(&mut self, matches: &[&Match], with: &str, new_step: bool) -> Replaced {
236        let mut done = Replaced::default();
237        for found in matches.iter().rev() {
238            let edit = Edit::Replace {
239                from: Position::new(found.paragraph.clone(), found.range.start),
240                to: Position::new(found.paragraph.clone(), found.range.end),
241                text: with,
242            };
243            if self.apply(edit, new_step && done.replaced == 0).is_some() {
244                done.replaced += 1;
245            } else {
246                done.skipped += 1;
247            }
248        }
249        done
250    }
251
252    /// Make the paragraphs a selection runs over a kind of paragraph, or take
253    /// them out of it where they already are one, as one step to undo, and put
254    /// the selection back over the same paragraphs. Answers whether the
255    /// document changed.
256    pub fn apply_block(&mut self, editor: &mut PageEditor, block: Block) -> bool {
257        let Some(selection) = editor.selection().cloned() else {
258            return false;
259        };
260        let Some(paragraphs) = self.selected_paragraphs(&selection) else {
261            return false;
262        };
263        let place = |path: &Vec<usize>, order: &[Vec<usize>]| order.iter().position(|p| p == path);
264        let (Some(anchor), Some(focus)) = (
265            place(&selection.anchor.paragraph, self.order()),
266            place(&selection.focus.paragraph, self.order()),
267        ) else {
268            return false;
269        };
270        let lit = self.is_block(&selection, block);
271        let before = self.content.clone();
272        let done = match block {
273            Block::Heading(_) | Block::Body => {
274                let level = match block {
275                    Block::Heading(level) if !lit => Some(level),
276                    _ => None,
277                };
278                self.set_headings(&paragraphs, level)
279            }
280            Block::Bullets | Block::Numbers => {
281                let kind = match block {
282                    Block::Bullets => ListKind::Bullet,
283                    _ => ListKind::Number,
284                };
285                self.set_lists(&paragraphs, (!lit).then_some(kind))
286            }
287        };
288        if done.is_none() || *self.content == before {
289            *self.content = before;
290            return false;
291        }
292        self.order = OnceCell::new();
293        let begins = Position::new(
294            [self.tag.as_slice(), &selection.focus.paragraph].concat(),
295            selection.focus.offset,
296        );
297        self.editing.touch();
298        self.editing.record_snapshot(before, Some(begins));
299        let order = self.order().to_vec();
300        if let (Some(a), Some(f)) = (order.get(anchor), order.get(focus)) {
301            editor.select(Selection {
302                anchor: Position::new(a.clone(), selection.anchor.offset),
303                focus: Position::new(f.clone(), selection.focus.offset),
304            });
305        }
306        true
307    }
308
309    fn set_headings(&mut self, paragraphs: &[Vec<usize>], level: Option<u8>) -> Option<()> {
310        let automatic =
311            |content: &Element| content.child(&Ns::Office, "automatic-styles").is_some();
312        for paragraph in paragraphs {
313            let had = automatic(self.content);
314            let path = [self.root.as_slice(), paragraph].concat();
315            edit::set_heading(self.content, &path, level, self.styles).ok()?;
316            self.follow_styles(had);
317        }
318        Some(())
319    }
320
321    fn set_lists(&mut self, paragraphs: &[Vec<usize>], kind: Option<ListKind>) -> Option<()> {
322        let had = self
323            .content
324            .child(&Ns::Office, "automatic-styles")
325            .is_some();
326        let paths: Vec<Vec<usize>> = paragraphs
327            .iter()
328            .map(|paragraph| [self.root.as_slice(), paragraph].concat())
329            .collect();
330        edit::set_list(self.content, &paths, kind, self.styles).ok()?;
331        self.follow_styles(had);
332        Some(())
333    }
334
335    /// Styles written into a document that had nowhere to keep them go before
336    /// its body, and move the body, and the root, one along.
337    fn follow_styles(&mut self, had: bool) {
338        if !had
339            && self
340                .content
341                .child(&Ns::Office, "automatic-styles")
342                .is_some()
343            && let Some(first) = self.root.first_mut()
344        {
345            *first += 1;
346        }
347    }
348
349    /// Split a paragraph in two. What follows a heading's last character is
350    /// body text and not another heading, as it is in every word processor.
351    fn split(&mut self, at: &Position<Vec<usize>>) -> Option<Vec<usize>> {
352        let at_end_of_heading = self
353            .root()
354            .and_then(|root| root.at(&at.paragraph))
355            .is_some_and(|paragraph| {
356                edit::heading_level(paragraph).is_some()
357                    && at.offset >= edit::text(paragraph).chars().count()
358            });
359        let root = self.content.at_mut(&self.root)?;
360        let second = edit::split_at(root, &at.paragraph, at.offset).ok()?;
361        if at_end_of_heading {
362            let path = [self.root.as_slice(), &second].concat();
363            // A document that cannot be given the paragraph style keeps the
364            // heading, which is a split all the same.
365            let _ = edit::set_heading(self.content, &path, None, self.styles);
366        }
367        Some(second)
368    }
369
370    /// Give the text from one position to another a mark, or take it off,
371    /// paragraph by paragraph.
372    fn format(
373        &mut self,
374        from: &Position<Vec<usize>>,
375        to: &Position<Vec<usize>>,
376        mark: edit::Mark,
377        on: bool,
378    ) -> Option<()> {
379        let automatic =
380            |content: &Element| content.child(&Ns::Office, "automatic-styles").is_some();
381        for (path, range) in self.covered(from, to)? {
382            if range.is_empty() {
383                continue;
384            }
385            let had = automatic(self.content);
386            let mut at = self.root.clone();
387            at.extend(&path);
388            edit::format(self.content, &at, range, mark, on, self.styles).ok()?;
389            // Styles written into a document that had nowhere to keep them go
390            // before its body, and move the body, and the root, one along.
391            if !had
392                && automatic(self.content)
393                && let Some(first) = self.root.first_mut()
394            {
395                *first += 1;
396            }
397        }
398        Some(())
399    }
400}
401
402/// The library's name for a mark.
403fn core_mark(mark: Mark) -> edit::Mark {
404    match mark {
405        Mark::Bold => edit::Mark::Bold,
406        Mark::Italic => edit::Mark::Italic,
407        Mark::Underline => edit::Mark::Underline,
408        Mark::Strike => edit::Mark::Strike,
409    }
410}
411
412impl Model for FlowModel<'_> {
413    type Paragraph = Vec<usize>;
414
415    fn text(&self, paragraph: &Vec<usize>) -> Option<String> {
416        self.root()?
417            .at(paragraph)
418            .filter(|element| is_paragraph(element))
419            .map(edit::text)
420    }
421
422    fn next(&self, paragraph: &Vec<usize>) -> Option<Vec<usize>> {
423        self.neighbour(paragraph, 1)
424    }
425
426    fn previous(&self, paragraph: &Vec<usize>) -> Option<Vec<usize>> {
427        self.neighbour(paragraph, -1)
428    }
429
430    fn first(&self) -> Option<Vec<usize>> {
431        self.order().first().cloned()
432    }
433
434    fn last(&self) -> Option<Vec<usize>> {
435        self.order().last().cloned()
436    }
437
438    fn marked(
439        &self,
440        from: &Position<Vec<usize>>,
441        to: &Position<Vec<usize>>,
442        mark: Mark,
443    ) -> Option<bool> {
444        let root = self.root()?;
445        let mark = core_mark(mark);
446        let ranges: Vec<_> = self
447            .covered(from, to)?
448            .into_iter()
449            .filter(|(_, range)| !range.is_empty())
450            .collect();
451        if ranges.is_empty() {
452            let at = from.offset..from.offset;
453            return edit::marked(root.at(&from.paragraph)?, at, mark, self.styles);
454        }
455        let mut answer = None;
456        for (path, range) in ranges {
457            let value = edit::marked(root.at(&path)?, range, mark, self.styles)?;
458            if answer.is_some_and(|a| a != value) {
459                return None;
460            }
461            answer = Some(value);
462        }
463        answer
464    }
465
466    fn fragment(&self, from: &Position<Vec<usize>>, to: &Position<Vec<usize>>) -> Option<Fragment> {
467        let root = self.root()?;
468        let items: Vec<Element> = self
469            .covered(from, to)?
470            .into_iter()
471            .filter_map(|(path, range)| Some(edit::slice(root.at(&path)?, range)))
472            .collect();
473        (!items.is_empty()).then(|| Fragment::new(items))
474    }
475
476    fn paste_fragment(
477        &mut self,
478        at: &Position<Vec<usize>>,
479        fragment: &Fragment,
480        new_step: bool,
481    ) -> Option<Position<Vec<usize>>> {
482        let items = fragment.get::<Vec<Element>>()?;
483        let new_step = new_step || !self.editing.modified();
484        let before = new_step.then(|| self.content.clone());
485        let root = self.content.at_mut(&self.root)?;
486        let (path, offset) = edit::paste_fragment(root, &at.paragraph, at.offset, items).ok()?;
487        self.editing.touch();
488        if let Some(before) = before {
489            let begins = Position::new([self.tag.as_slice(), &at.paragraph].concat(), at.offset);
490            self.editing.record_snapshot(before, Some(begins));
491        }
492        self.order = OnceCell::new();
493        Some(Position::new(path, offset))
494    }
495
496    fn apply(
497        &mut self,
498        edit: Edit<'_, Vec<usize>>,
499        new_step: bool,
500    ) -> Option<Position<Vec<usize>>> {
501        // An edit to a document that matches its file always begins a step,
502        // whatever the editor thinks: typing that runs on past a save is a
503        // change the save does not hold, and has to be one a close asks about.
504        let new_step = new_step || !self.editing.modified();
505        // Taken before trying, because only trying says whether the edit is
506        // made. A refusal is decided before anything in the tree changes.
507        // Where the edit begins is where an undo of it puts the caret.
508        let before = new_step.then(|| self.content.clone());
509        let begins = match &edit {
510            Edit::Replace { from, .. } | Edit::Format { from, .. } => from,
511            Edit::Split { at } => at,
512        };
513        let begins = Position::new(
514            [self.tag.as_slice(), &begins.paragraph].concat(),
515            begins.offset,
516        );
517        let at = match edit {
518            Edit::Replace { from, to, text } => self
519                .replace(&from, &to, text)
520                .map(|()| Position::new(from.paragraph, from.offset + text.chars().count())),
521            Edit::Split { at } => self.split(&at).map(|second| Position::new(second, 0)),
522            Edit::Format { from, to, mark, on } => {
523                self.format(&from, &to, core_mark(mark), on).map(|()| to)
524            }
525        };
526        if at.is_some() {
527            self.editing.touch();
528            if let Some(before) = before {
529                self.editing.record_snapshot(before, Some(begins));
530            }
531            self.order = OnceCell::new();
532        }
533        at
534    }
535}
536
537/// Every paragraph under a root, in the order [`crate::Flow`] draws them, by
538/// the paths it reports them under.
539pub(crate) fn paragraph_paths(root: &Element) -> Vec<Vec<usize>> {
540    let mut out = Vec::new();
541    blocks(root, &mut Vec::new(), &mut out);
542    out
543}
544
545/// The paragraphs among a run of blocks, in the order [`crate::Flow`] draws
546/// them, and under the same paths.
547fn blocks(parent: &Element, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
548    for (index, element) in parent.elements_indexed() {
549        path.push(index);
550        block(element, path, out);
551        path.pop();
552    }
553}
554
555fn block(element: &Element, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
556    if is_paragraph(element) {
557        out.push(path.clone());
558    } else if element.is(&Ns::Text, "list") {
559        for (item_index, item) in element.elements_indexed() {
560            if item.is(&Ns::Text, "list-item") || item.is(&Ns::Text, "list-header") {
561                path.push(item_index);
562                blocks(item, path, out);
563                path.pop();
564            }
565        }
566    } else if element.is(&Ns::Table, "table")
567        || element.is(&Ns::Table, "table-header-rows")
568        || element.is(&Ns::Table, "table-rows")
569        || element.is(&Ns::Table, "table-row-group")
570    {
571        blocks(element, path, out);
572    } else if element.is(&Ns::Table, "table-row") {
573        for (cell_index, cell) in element.elements_indexed() {
574            if cell.is(&Ns::Table, "table-cell") {
575                path.push(cell_index);
576                blocks(cell, path, out);
577                path.pop();
578            }
579        }
580    } else if element.is(&Ns::Draw, "frame") {
581        if let Some((box_index, text_box)) = element
582            .elements_indexed()
583            .find(|(_, e)| e.is(&Ns::Draw, "text-box"))
584        {
585            path.push(box_index);
586            blocks(text_box, path, out);
587            path.pop();
588        }
589    } else if element.name.ns == Ns::Draw {
590        // A drawing shape keeps its label as paragraphs of its own, where a
591        // frame keeps them in a text box.
592        blocks(element, path, out);
593    } else if is_block_container(element) {
594        blocks(element, path, out);
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::FlowModel;
601    use crate::Editing;
602    use egui_richedit::{Edit, Model, Position};
603    use odox_core::{Element, Node, Ns, Styles};
604
605    fn element(prefix: &str, local: &str, ns: Ns, children: Vec<Node>) -> Node {
606        let mut e = Element::new(prefix, local, ns);
607        e.children = children;
608        Node::Element(e)
609    }
610
611    fn p(text: &str) -> Node {
612        element("text", "p", Ns::Text, vec![Node::Text(text.to_owned())])
613    }
614
615    /// `office:text` holding a paragraph, a list of one item, a table of one
616    /// cell, and a paragraph, under a content root.
617    fn content() -> Element {
618        let list = element(
619            "text",
620            "list",
621            Ns::Text,
622            vec![element("text", "list-item", Ns::Text, vec![p("item")])],
623        );
624        let table = element(
625            "table",
626            "table",
627            Ns::Table,
628            vec![element(
629                "table",
630                "table-row",
631                Ns::Table,
632                vec![element("table", "table-cell", Ns::Table, vec![p("cell")])],
633            )],
634        );
635        let body = element(
636            "office",
637            "text",
638            Ns::Office,
639            vec![p("one"), list, table, p("two")],
640        );
641        let mut root = Element::new("office", "document-content", Ns::Office);
642        root.children = vec![element("office", "body", Ns::Office, vec![body])];
643        root
644    }
645
646    #[test]
647    fn paragraphs_follow_one_another_through_lists_and_tables() {
648        let mut content = content();
649        let mut styles = Styles::collect(Some(&content), None);
650        let mut editing = Editing::default();
651        let model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
652        let mut at = vec![0];
653        let mut seen = vec![model.text(&at).expect("the first")];
654        while let Some(next) = model.next(&at) {
655            seen.push(model.text(&next).expect("a paragraph"));
656            assert_eq!(model.previous(&next), Some(at));
657            at = next;
658        }
659        assert_eq!(seen, ["one", "item", "cell", "two"]);
660    }
661
662    #[test]
663    fn a_selection_over_a_whole_table_takes_it_and_joins_the_ends() {
664        let mut content = content();
665        let mut styles = Styles::collect(Some(&content), None);
666        let mut editing = Editing::default();
667        editing.reset();
668        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
669        // From inside "item" to inside "two", over the table's one cell.
670        let at = model.apply(
671            Edit::Replace {
672                from: Position::new(vec![1, 0, 0], 2),
673                to: Position::new(vec![3], 1),
674                text: "X",
675            },
676            true,
677        );
678        assert_eq!(at, Some(Position::new(vec![1, 0, 0], 3)));
679        assert_eq!(model.text(&vec![1, 0, 0]).as_deref(), Some("itXwo"));
680        assert_eq!(
681            model.next(&vec![1, 0, 0]),
682            None,
683            "the table and \"two\" are gone"
684        );
685    }
686
687    #[test]
688    fn a_selection_out_of_a_cell_takes_the_text_and_leaves_the_cell() {
689        let mut content = content();
690        let mut styles = Styles::collect(Some(&content), None);
691        let mut editing = Editing::default();
692        editing.reset();
693        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
694        // From inside "cell" to inside "two".
695        let at = model.apply(
696            Edit::Replace {
697                from: Position::new(vec![2, 0, 0, 0], 2),
698                to: Position::new(vec![3], 1),
699                text: "",
700            },
701            true,
702        );
703        assert_eq!(at, Some(Position::new(vec![2, 0, 0, 0], 2)));
704        assert_eq!(model.text(&vec![2, 0, 0, 0]).as_deref(), Some("ce"));
705        assert_eq!(model.text(&vec![3]).as_deref(), Some("wo"));
706    }
707
708    #[test]
709    fn backspace_at_a_list_item_joins_it_to_the_paragraph_before() {
710        let mut content = content();
711        let mut styles = Styles::collect(Some(&content), None);
712        let mut editing = Editing::default();
713        editing.reset();
714        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
715        let at = model.apply(
716            Edit::Replace {
717                from: Position::new(vec![0], 3),
718                to: Position::new(vec![1, 0, 0], 0),
719                text: "",
720            },
721            true,
722        );
723        assert_eq!(at, Some(Position::new(vec![0], 3)));
724        assert_eq!(model.text(&vec![0]).as_deref(), Some("oneitem"));
725        // The list had one item, and went with it.
726        assert_eq!(model.text(&vec![1, 0, 0, 0]).as_deref(), Some("cell"));
727    }
728
729    #[test]
730    fn a_tagged_model_remembers_the_caret_under_its_tag() {
731        let mut content = content();
732        let mut styles = Styles::collect(Some(&content), None);
733        let mut editing = Editing::default();
734        editing.reset();
735        let mut model =
736            FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing).tagged(vec![7]);
737        model.apply(
738            Edit::Split {
739                at: Position::new(vec![0], 1),
740            },
741            true,
742        );
743        let current = content.clone();
744        let (_, caret) = editing.undo(&current, None).expect("a step to undo");
745        assert_eq!(caret, Some(Position::new(vec![7, 0], 1)));
746    }
747
748    fn heading_document() -> Element {
749        odox_core::xml::parse(
750            br#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:body><office:text><text:h text:outline-level="1" text:style-name="H">Title</text:h><text:p text:style-name="Body">a</text:p><text:p text:style-name="Body">b</text:p></office:text></office:body></office:document-content>"#,
751            "test",
752        )
753        .expect("a document")
754    }
755
756    #[test]
757    fn enter_at_the_end_of_a_heading_starts_body_text() {
758        let mut content = heading_document();
759        let mut styles = Styles::collect(Some(&content), None);
760        let mut editing = Editing::default();
761        editing.reset();
762        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
763        let at = model.apply(
764            Edit::Split {
765                at: Position::new(vec![0], 5),
766            },
767            true,
768        );
769        assert_eq!(at, Some(Position::new(vec![1], 0)));
770        let second = content.at(&[0, 0, 1]).expect("the new paragraph");
771        assert!(second.is(&Ns::Text, "p"));
772        assert_eq!(second.attr(&Ns::Text, "style-name"), Some("Body"));
773        assert!(
774            content
775                .at(&[0, 0, 0])
776                .expect("the heading")
777                .is(&Ns::Text, "h")
778        );
779    }
780
781    #[test]
782    fn replacing_every_match_is_one_step_and_the_offsets_hold() {
783        let mut content = heading_document();
784        let mut styles = Styles::collect(Some(&content), None);
785        let mut editing = Editing::default();
786        editing.reset();
787        let matches = crate::find::in_paragraphs(content.at(&[0, 0]).expect("the body"), 0, "t");
788        assert_eq!(matches.len(), 2, "the two t of Title");
789        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
790        let done = model.replace_matches(&matches.iter().collect::<Vec<_>>(), "TT", true);
791        assert_eq!(done.replaced, 2);
792        assert_eq!(model.text(&vec![0]).as_deref(), Some("TTiTTle"));
793        let mut undone = 0;
794        let mut current = content.clone();
795        while let Some((previous, _)) = editing.undo(&current, None) {
796            current = previous;
797            undone += 1;
798        }
799        assert_eq!(undone, 1);
800    }
801
802    #[test]
803    fn a_copy_keeps_spans_and_a_paste_puts_them_back_in_one_step() {
804        let mut content = odox_core::xml::parse(
805            br#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p>one <text:span text:style-name="T1">two</text:span> three</text:p><text:p>x</text:p></office:text></office:body></office:document-content>"#,
806            "test",
807        )
808        .expect("a document");
809        let mut styles = Styles::collect(Some(&content), None);
810        let mut editing = Editing::default();
811        editing.reset();
812        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
813        let fragment = model
814            .fragment(&Position::new(vec![0], 3), &Position::new(vec![0], 7))
815            .expect("a copy");
816        let after = model
817            .paste_fragment(&Position::new(vec![1], 1), &fragment, true)
818            .expect("it goes in");
819        assert_eq!(after, Position::new(vec![1], 5));
820        let written = String::from_utf8(odox_core::xml::serialize(&content)).expect("UTF-8");
821        assert!(
822            written.contains(
823                r#"<text:p>x<text:s/><text:span text:style-name="T1">two</text:span></text:p>"#
824            ),
825            "{written}"
826        );
827        assert_eq!(editing.undo(&content.clone(), None).map(|_| ()), Some(()));
828        assert!(!editing.can_undo(), "one step");
829    }
830
831    #[test]
832    fn enter_in_the_middle_of_a_heading_leaves_two_headings() {
833        let mut content = heading_document();
834        let mut styles = Styles::collect(Some(&content), None);
835        let mut editing = Editing::default();
836        editing.reset();
837        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
838        model.apply(
839            Edit::Split {
840                at: Position::new(vec![0], 2),
841            },
842            true,
843        );
844        assert!(
845            content
846                .at(&[0, 0, 1])
847                .expect("the second half")
848                .is(&Ns::Text, "h")
849        );
850    }
851
852    #[test]
853    fn typing_that_runs_on_past_a_save_is_a_change() {
854        let mut content = content();
855        let mut styles = Styles::collect(Some(&content), None);
856        let mut editing = Editing::default();
857        editing.reset();
858        let at = |offset| Position::new(vec![0], offset);
859        let typed = |offset| Edit::Replace {
860            from: at(offset),
861            to: at(offset),
862            text: "x",
863        };
864        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
865        model.apply(typed(0), true);
866        model.editing.mark_saved();
867        // The same run of typing, as far as the editor knows.
868        model.apply(typed(1), false);
869        assert!(
870            editing.modified(),
871            "what was typed after the save is unsaved"
872        );
873    }
874
875    #[test]
876    fn an_edit_that_begins_a_step_is_recorded_once_it_succeeds() {
877        let mut content = content();
878        let mut styles = Styles::collect(Some(&content), None);
879        let mut editing = Editing::default();
880        editing.reset();
881        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
882        let at = model.apply(
883            Edit::Split {
884                at: Position::new(vec![0], 1),
885            },
886            true,
887        );
888        assert_eq!(at, Some(Position::new(vec![1], 0)));
889        assert_eq!(model.text(&vec![0]).as_deref(), Some("o"));
890        assert_eq!(model.text(&vec![1]).as_deref(), Some("ne"));
891
892        // Backspace at the start of a table's cell: nothing to join and
893        // nothing to remove, which is refused and is not a step.
894        let refused = model.apply(
895            Edit::Replace {
896                from: Position::new(vec![2, 0, 0], 4),
897                to: Position::new(vec![3, 0, 0, 0], 0),
898                text: "",
899            },
900            true,
901        );
902        assert_eq!(refused, None);
903        assert!(editing.can_undo());
904        let mut undone = 0;
905        let mut current = content.clone();
906        while let Some(previous) = editing.undo(&current, None).map(|(tree, _)| tree) {
907            current = previous;
908            undone += 1;
909        }
910        assert_eq!(undone, 1);
911    }
912}