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, Mark, Model, Position, RichEdit};
18use odox_core::edit::{self, is_paragraph};
19use odox_core::{Element, Ns, Refused, Styles};
20
21use crate::Editing;
22use crate::flow::is_block_container;
23
24/// The editor over a flow's paragraphs.
25pub type PageEditor = RichEdit<Vec<usize>>;
26
27/// The id the page editor takes the keyboard under. The shell reads it to
28/// know that typing on the page still leaves its shortcuts to it.
29pub(crate) fn page_editor_id() -> Id {
30    Id::new("odox-page-editor")
31}
32
33/// A page editor with nothing selected.
34pub fn page_editor() -> PageEditor {
35    RichEdit::new(page_editor_id())
36}
37
38/// The paragraphs under a root in a content tree, for one frame's editing.
39pub struct FlowModel<'a> {
40    content: &'a mut Element,
41    styles: &'a mut Styles,
42    /// Where the flow's root is under the content root.
43    root: Vec<usize>,
44    editing: &'a mut Editing,
45    /// The part of the root the paragraphs are kept to, as a path under it:
46    /// on a slide, the one shape being typed into.
47    scope: Vec<usize>,
48    /// Every editable paragraph in drawing order, found when first asked for
49    /// and forgotten when an edit changes it.
50    order: OnceCell<Vec<Vec<usize>>>,
51}
52
53impl<'a> FlowModel<'a> {
54    /// The paragraphs under the element at a path in a content tree, whose
55    /// document's styles are `styles`.
56    pub fn new(
57        content: &'a mut Element,
58        styles: &'a mut Styles,
59        root: Vec<usize>,
60        editing: &'a mut Editing,
61    ) -> Self {
62        Self {
63            content,
64            styles,
65            root,
66            editing,
67            scope: Vec::new(),
68            order: OnceCell::new(),
69        }
70    }
71
72    /// Keep to the paragraphs under one element beneath the root, so that
73    /// moving and joining never leave it: a slide's label does not run on
74    /// into the next shape.
75    #[must_use]
76    pub fn within(mut self, scope: Vec<usize>) -> Self {
77        self.scope = scope;
78        self
79    }
80
81    fn root(&self) -> Option<&Element> {
82        self.content.at(&self.root)
83    }
84
85    fn paragraph_mut(&mut self, path: &[usize]) -> Option<&mut Element> {
86        self.content
87            .at_mut(&self.root)?
88            .at_mut(path)
89            .filter(|element| is_paragraph(element))
90    }
91
92    fn order(&self) -> &[Vec<usize>] {
93        self.order.get_or_init(|| {
94            let mut out = Vec::new();
95            if let Some(root) = self.root() {
96                blocks(root, &mut Vec::new(), &mut out);
97            }
98            out.retain(|path| path.starts_with(&self.scope));
99            out
100        })
101    }
102
103    fn neighbour(&self, paragraph: &[usize], step: isize) -> Option<Vec<usize>> {
104        let order = self.order();
105        let index = order.iter().position(|p| p == paragraph)?;
106        order.get(index.checked_add_signed(step)?).cloned()
107    }
108
109    fn replace(
110        &mut self,
111        from: &Position<Vec<usize>>,
112        to: &Position<Vec<usize>>,
113        text: &str,
114    ) -> Option<()> {
115        let root = self.content.at_mut(&self.root)?;
116        match edit::replace_range(
117            root,
118            (&from.paragraph, from.offset),
119            (&to.paragraph, to.offset),
120            text,
121        ) {
122            Ok(()) => Some(()),
123            Err(Refused::Structure) => self.clear_between(from, to, text),
124            Err(_) => None,
125        }
126    }
127
128    /// A range that crosses a table or a frame, which no join takes apart:
129    /// the selected text goes from every paragraph it covers, each paragraph
130    /// stays where it is, and the new text goes in at the start. Nothing to
131    /// remove and nothing to put in, as Backspace at the start of a cell, is
132    /// refused rather than made an edit of nothing.
133    fn clear_between(
134        &mut self,
135        from: &Position<Vec<usize>>,
136        to: &Position<Vec<usize>>,
137        text: &str,
138    ) -> Option<()> {
139        let ranges = self.covered(from, to)?;
140        if text.is_empty() && ranges.iter().all(|(_, range)| range.is_empty()) {
141            return None;
142        }
143        for (path, range) in ranges.into_iter().rev() {
144            let with = if path == from.paragraph { text } else { "" };
145            let paragraph = self.paragraph_mut(&path)?;
146            edit::replace(paragraph, range, with);
147        }
148        Some(())
149    }
150
151    /// Each paragraph from one position to another in drawing order, with
152    /// the range of its text between them.
153    fn covered(
154        &self,
155        from: &Position<Vec<usize>>,
156        to: &Position<Vec<usize>>,
157    ) -> Option<Vec<(Vec<usize>, Range<usize>)>> {
158        let order = self.order();
159        let first = order.iter().position(|p| *p == from.paragraph)?;
160        let last = order.iter().position(|p| *p == to.paragraph)?;
161        let covered: Vec<Vec<usize>> = order.get(first..=last)?.to_vec();
162        Some(
163            covered
164                .into_iter()
165                .map(|path| {
166                    let len = self.text(&path).map_or(0, |t| t.chars().count());
167                    let start = if path == from.paragraph {
168                        from.offset
169                    } else {
170                        0
171                    };
172                    let end = if path == to.paragraph { to.offset } else { len };
173                    (path, start..end.max(start))
174                })
175                .collect(),
176        )
177    }
178
179    /// Give the text from one position to another a mark, or take it off,
180    /// paragraph by paragraph.
181    fn format(
182        &mut self,
183        from: &Position<Vec<usize>>,
184        to: &Position<Vec<usize>>,
185        mark: edit::Mark,
186        on: bool,
187    ) -> Option<()> {
188        let automatic =
189            |content: &Element| content.child(&Ns::Office, "automatic-styles").is_some();
190        for (path, range) in self.covered(from, to)? {
191            if range.is_empty() {
192                continue;
193            }
194            let had = automatic(self.content);
195            let mut at = self.root.clone();
196            at.extend(&path);
197            edit::format(self.content, &at, range, mark, on, self.styles).ok()?;
198            // Styles written into a document that had nowhere to keep them go
199            // before its body, and move the body, and the root, one along.
200            if !had
201                && automatic(self.content)
202                && let Some(first) = self.root.first_mut()
203            {
204                *first += 1;
205            }
206        }
207        Some(())
208    }
209}
210
211/// The library's name for a mark.
212fn core_mark(mark: Mark) -> edit::Mark {
213    match mark {
214        Mark::Bold => edit::Mark::Bold,
215        Mark::Italic => edit::Mark::Italic,
216        Mark::Underline => edit::Mark::Underline,
217        Mark::Strike => edit::Mark::Strike,
218    }
219}
220
221impl Model for FlowModel<'_> {
222    type Paragraph = Vec<usize>;
223
224    fn text(&self, paragraph: &Vec<usize>) -> Option<String> {
225        self.root()?
226            .at(paragraph)
227            .filter(|element| is_paragraph(element))
228            .map(edit::text)
229    }
230
231    fn next(&self, paragraph: &Vec<usize>) -> Option<Vec<usize>> {
232        self.neighbour(paragraph, 1)
233    }
234
235    fn previous(&self, paragraph: &Vec<usize>) -> Option<Vec<usize>> {
236        self.neighbour(paragraph, -1)
237    }
238
239    fn first(&self) -> Option<Vec<usize>> {
240        self.order().first().cloned()
241    }
242
243    fn last(&self) -> Option<Vec<usize>> {
244        self.order().last().cloned()
245    }
246
247    fn marked(
248        &self,
249        from: &Position<Vec<usize>>,
250        to: &Position<Vec<usize>>,
251        mark: Mark,
252    ) -> Option<bool> {
253        let root = self.root()?;
254        let mark = core_mark(mark);
255        let ranges: Vec<_> = self
256            .covered(from, to)?
257            .into_iter()
258            .filter(|(_, range)| !range.is_empty())
259            .collect();
260        if ranges.is_empty() {
261            let at = from.offset..from.offset;
262            return edit::marked(root.at(&from.paragraph)?, at, mark, self.styles);
263        }
264        let mut answer = None;
265        for (path, range) in ranges {
266            let value = edit::marked(root.at(&path)?, range, mark, self.styles)?;
267            if answer.is_some_and(|a| a != value) {
268                return None;
269            }
270            answer = Some(value);
271        }
272        answer
273    }
274
275    fn apply(
276        &mut self,
277        edit: Edit<'_, Vec<usize>>,
278        new_step: bool,
279    ) -> Option<Position<Vec<usize>>> {
280        // An edit to a document that matches its file always begins a step,
281        // whatever the editor thinks: typing that runs on past a save is a
282        // change the save does not hold, and has to be one a close asks about.
283        let new_step = new_step || !self.editing.modified();
284        // Taken before trying, because only trying says whether the edit is
285        // made. A refusal is decided before anything in the tree changes.
286        // Where the edit begins is where an undo of it puts the caret.
287        let before = new_step.then(|| self.content.clone());
288        let begins = match &edit {
289            Edit::Replace { from, .. } | Edit::Format { from, .. } => from.clone(),
290            Edit::Split { at } => at.clone(),
291        };
292        let at = match edit {
293            Edit::Replace { from, to, text } => self
294                .replace(&from, &to, text)
295                .map(|()| Position::new(from.paragraph, from.offset + text.chars().count())),
296            Edit::Split { at } => {
297                let root = self.content.at_mut(&self.root)?;
298                edit::split_at(root, &at.paragraph, at.offset)
299                    .ok()
300                    .map(|second| Position::new(second, 0))
301            }
302            Edit::Format { from, to, mark, on } => {
303                self.format(&from, &to, core_mark(mark), on).map(|()| to)
304            }
305        };
306        if at.is_some() {
307            if let Some(before) = before {
308                self.editing.record_snapshot(before, Some(begins));
309            }
310            self.order = OnceCell::new();
311        }
312        at
313    }
314}
315
316/// The paragraphs among a run of blocks, in the order [`crate::Flow`] draws
317/// them, and under the same paths.
318fn blocks(parent: &Element, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
319    for (index, element) in parent.elements_indexed() {
320        path.push(index);
321        block(element, path, out);
322        path.pop();
323    }
324}
325
326fn block(element: &Element, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
327    if is_paragraph(element) {
328        out.push(path.clone());
329    } else if element.is(&Ns::Text, "list") {
330        for (item_index, item) in element.elements_indexed() {
331            if item.is(&Ns::Text, "list-item") || item.is(&Ns::Text, "list-header") {
332                path.push(item_index);
333                blocks(item, path, out);
334                path.pop();
335            }
336        }
337    } else if element.is(&Ns::Table, "table")
338        || element.is(&Ns::Table, "table-header-rows")
339        || element.is(&Ns::Table, "table-rows")
340        || element.is(&Ns::Table, "table-row-group")
341    {
342        blocks(element, path, out);
343    } else if element.is(&Ns::Table, "table-row") {
344        for (cell_index, cell) in element.elements_indexed() {
345            if cell.is(&Ns::Table, "table-cell") {
346                path.push(cell_index);
347                blocks(cell, path, out);
348                path.pop();
349            }
350        }
351    } else if element.is(&Ns::Draw, "frame") {
352        if let Some((box_index, text_box)) = element
353            .elements_indexed()
354            .find(|(_, e)| e.is(&Ns::Draw, "text-box"))
355        {
356            path.push(box_index);
357            blocks(text_box, path, out);
358            path.pop();
359        }
360    } else if element.name.ns == Ns::Draw {
361        // A drawing shape keeps its label as paragraphs of its own, where a
362        // frame keeps them in a text box.
363        blocks(element, path, out);
364    } else if is_block_container(element) {
365        blocks(element, path, out);
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::FlowModel;
372    use crate::Editing;
373    use egui_richedit::{Edit, Model, Position};
374    use odox_core::{Element, Node, Ns, Styles};
375
376    fn element(prefix: &str, local: &str, ns: Ns, children: Vec<Node>) -> Node {
377        let mut e = Element::new(prefix, local, ns);
378        e.children = children;
379        Node::Element(e)
380    }
381
382    fn p(text: &str) -> Node {
383        element("text", "p", Ns::Text, vec![Node::Text(text.to_owned())])
384    }
385
386    /// `office:text` holding a paragraph, a list of one item, a table of one
387    /// cell, and a paragraph, under a content root.
388    fn content() -> Element {
389        let list = element(
390            "text",
391            "list",
392            Ns::Text,
393            vec![element("text", "list-item", Ns::Text, vec![p("item")])],
394        );
395        let table = element(
396            "table",
397            "table",
398            Ns::Table,
399            vec![element(
400                "table",
401                "table-row",
402                Ns::Table,
403                vec![element("table", "table-cell", Ns::Table, vec![p("cell")])],
404            )],
405        );
406        let body = element(
407            "office",
408            "text",
409            Ns::Office,
410            vec![p("one"), list, table, p("two")],
411        );
412        let mut root = Element::new("office", "document-content", Ns::Office);
413        root.children = vec![element("office", "body", Ns::Office, vec![body])];
414        root
415    }
416
417    #[test]
418    fn paragraphs_follow_one_another_through_lists_and_tables() {
419        let mut content = content();
420        let mut styles = Styles::collect(Some(&content), None);
421        let mut editing = Editing::default();
422        let model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
423        let mut at = vec![0];
424        let mut seen = vec![model.text(&at).expect("the first")];
425        while let Some(next) = model.next(&at) {
426            seen.push(model.text(&next).expect("a paragraph"));
427            assert_eq!(model.previous(&next), Some(at));
428            at = next;
429        }
430        assert_eq!(seen, ["one", "item", "cell", "two"]);
431    }
432
433    #[test]
434    fn a_selection_over_a_whole_table_takes_it_and_joins_the_ends() {
435        let mut content = content();
436        let mut styles = Styles::collect(Some(&content), None);
437        let mut editing = Editing::default();
438        editing.reset();
439        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
440        // From inside "item" to inside "two", over the table's one cell.
441        let at = model.apply(
442            Edit::Replace {
443                from: Position::new(vec![1, 0, 0], 2),
444                to: Position::new(vec![3], 1),
445                text: "X",
446            },
447            true,
448        );
449        assert_eq!(at, Some(Position::new(vec![1, 0, 0], 3)));
450        assert_eq!(model.text(&vec![1, 0, 0]).as_deref(), Some("itXwo"));
451        assert_eq!(
452            model.next(&vec![1, 0, 0]),
453            None,
454            "the table and \"two\" are gone"
455        );
456    }
457
458    #[test]
459    fn a_selection_out_of_a_cell_takes_the_text_and_leaves_the_cell() {
460        let mut content = content();
461        let mut styles = Styles::collect(Some(&content), None);
462        let mut editing = Editing::default();
463        editing.reset();
464        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
465        // From inside "cell" to inside "two".
466        let at = model.apply(
467            Edit::Replace {
468                from: Position::new(vec![2, 0, 0, 0], 2),
469                to: Position::new(vec![3], 1),
470                text: "",
471            },
472            true,
473        );
474        assert_eq!(at, Some(Position::new(vec![2, 0, 0, 0], 2)));
475        assert_eq!(model.text(&vec![2, 0, 0, 0]).as_deref(), Some("ce"));
476        assert_eq!(model.text(&vec![3]).as_deref(), Some("wo"));
477    }
478
479    #[test]
480    fn backspace_at_a_list_item_joins_it_to_the_paragraph_before() {
481        let mut content = content();
482        let mut styles = Styles::collect(Some(&content), None);
483        let mut editing = Editing::default();
484        editing.reset();
485        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
486        let at = model.apply(
487            Edit::Replace {
488                from: Position::new(vec![0], 3),
489                to: Position::new(vec![1, 0, 0], 0),
490                text: "",
491            },
492            true,
493        );
494        assert_eq!(at, Some(Position::new(vec![0], 3)));
495        assert_eq!(model.text(&vec![0]).as_deref(), Some("oneitem"));
496        // The list had one item, and went with it.
497        assert_eq!(model.text(&vec![1, 0, 0, 0]).as_deref(), Some("cell"));
498    }
499
500    #[test]
501    fn typing_that_runs_on_past_a_save_is_a_change() {
502        let mut content = content();
503        let mut styles = Styles::collect(Some(&content), None);
504        let mut editing = Editing::default();
505        editing.reset();
506        let at = |offset| Position::new(vec![0], offset);
507        let typed = |offset| Edit::Replace {
508            from: at(offset),
509            to: at(offset),
510            text: "x",
511        };
512        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
513        model.apply(typed(0), true);
514        model.editing.mark_saved();
515        // The same run of typing, as far as the editor knows.
516        model.apply(typed(1), false);
517        assert!(
518            editing.modified(),
519            "what was typed after the save is unsaved"
520        );
521    }
522
523    #[test]
524    fn an_edit_that_begins_a_step_is_recorded_once_it_succeeds() {
525        let mut content = content();
526        let mut styles = Styles::collect(Some(&content), None);
527        let mut editing = Editing::default();
528        editing.reset();
529        let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing);
530        let at = model.apply(
531            Edit::Split {
532                at: Position::new(vec![0], 1),
533            },
534            true,
535        );
536        assert_eq!(at, Some(Position::new(vec![1], 0)));
537        assert_eq!(model.text(&vec![0]).as_deref(), Some("o"));
538        assert_eq!(model.text(&vec![1]).as_deref(), Some("ne"));
539
540        // Backspace at the start of a table's cell: nothing to join and
541        // nothing to remove, which is refused and is not a step.
542        let refused = model.apply(
543            Edit::Replace {
544                from: Position::new(vec![2, 0, 0], 4),
545                to: Position::new(vec![3, 0, 0, 0], 0),
546                text: "",
547            },
548            true,
549        );
550        assert_eq!(refused, None);
551        assert!(editing.can_undo());
552        let mut undone = 0;
553        let mut current = content.clone();
554        while let Some(previous) = editing.undo(&current, None).map(|(tree, _)| tree) {
555            current = previous;
556            undone += 1;
557        }
558        assert_eq!(undone, 1);
559    }
560}