diff --git a/Cargo.lock b/Cargo.lock index 6bf20d2837fa08a080aa0d3d68222c77f9fa7e13..34a6fe55afe48023c7ef60de5d8340d15e9c73d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,6 +792,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1229,6 +1235,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -2282,6 +2289,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -2927,6 +2940,20 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/crates/canvas/assets/tags/address.svg b/crates/canvas/assets/tags/address.svg new file mode 100644 index 0000000000000000000000000000000000000000..428250519b22d61e99daa3f5a71634f66d0043f1 --- /dev/null +++ b/crates/canvas/assets/tags/address.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/contact.svg b/crates/canvas/assets/tags/contact.svg new file mode 100644 index 0000000000000000000000000000000000000000..33d2f91d9b16edbf8434235264e8e4c1b158749c --- /dev/null +++ b/crates/canvas/assets/tags/contact.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/highlight.svg b/crates/canvas/assets/tags/highlight.svg new file mode 100644 index 0000000000000000000000000000000000000000..9922e577f6a695a0c1b4597805fc654a293811c4 --- /dev/null +++ b/crates/canvas/assets/tags/highlight.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/phone.svg b/crates/canvas/assets/tags/phone.svg new file mode 100644 index 0000000000000000000000000000000000000000..07d6349b3f96797e1cba895eff3d0b7ca84b16d0 --- /dev/null +++ b/crates/canvas/assets/tags/phone.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/star.svg b/crates/canvas/assets/tags/star.svg new file mode 100644 index 0000000000000000000000000000000000000000..62f340191717c95b0a5fbc21a2522a3241ed68ad --- /dev/null +++ b/crates/canvas/assets/tags/star.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs index fc7cfab6c42caa1d69af24a9b70837e03db109ba..0ef98c2460670404ca1450e061750bd06efe0dd9 100644 --- a/crates/canvas/src/date.rs +++ b/crates/canvas/src/date.rs @@ -72,13 +72,17 @@ impl PageDate { if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 { return Err(EditError::UnsupportedContent.into()); } - let document = TextDocument::from_nodes(source.paragraphs.clone())?; - document.validate_flat()?; - if source - .paragraphs - .iter() - .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1) - { + TextDocument::from_nodes(source.paragraphs.clone())?; + // A date field is one plain run in a plain paragraph. + if source.paragraphs.iter().any(|paragraph| { + !paragraph.lists.is_empty() + || !paragraph.tags.is_empty() + || paragraph.collapsed + || paragraph.parent.is_some() + || paragraph + .text() + .is_none_or(|text| !text.tags.is_empty() || text.text.spans().len() != 1) + }) { return Err(EditError::UnsupportedContent.into()); } let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?; diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 08d18edf13876cd2278b99968f8c3bd5c9b86747..2cf577707378dae05471f0c125696899da26cd19 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator }) } -pub(crate) fn validate_flat<'a>( - mut nodes: impl Iterator, -) -> Result<(), EditError> { - if nodes.any(|node| { - !node.lists.is_empty() - || !node.tags.is_empty() - || node.collapsed - || node.parent.is_some() - || node.text().is_none_or(|text| !text.tags.is_empty()) - }) { - return Err(EditError::UnsupportedContent); - } - Ok(()) +/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor +/// contiguously; a paragraph only an outline group indents has no parent to descend from. +pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize { + let mut members = BTreeSet::from([nodes[index].id]); + index + + 1 + + nodes[index + 1..] + .iter() + .take_while(|node| { + node.parent.is_some_and(|parent| members.contains(&parent)) + && members.insert(node.id) + }) + .count() +} + +/// The nodes from `from` on that descend from a paragraph in `moves` or `shifts`, rebuilt so +/// the children of each paragraph in `moves` belong to its new parent, one level below it, and +/// every subtree keeps its depth below its root; ends at the last node that changes. +fn adopt( + nodes: &[PageParagraph], + from: usize, + moves: &BTreeMap, + mut shifts: BTreeMap, +) -> Result, EditError> { + let mut adopted = Vec::new(); + let mut changed = 0; + for node in &nodes[from..] { + let Some(parent) = node.parent else { break }; + let mut node = node.clone(); + let shift = match (moves.get(&parent), shifts.get(&parent)) { + (Some(holder), _) => { + node.parent = Some(holder.id); + i64::from(holder.level) + 1 - i64::from(node.level) + } + (None, Some(shift)) => *shift, + (None, None) => break, + }; + node.level = u32::try_from(i64::from(node.level) + shift) + .map_err(|_| EditError::InvalidStructure)?; + shifts.insert(node.id, shift); + if shift != 0 || moves.contains_key(&parent) { + changed = adopted.len() + 1; + } + adopted.push(node); + } + adopted.truncate(changed); + Ok(adopted) +} + +/// The sibling `nodes[index]` follows, passing over `skipped` siblings, when everything +/// between them descends from it or from a skipped paragraph. +pub(crate) fn previous_sibling( + nodes: &[PageParagraph], + index: usize, + skipped: &BTreeSet, +) -> Option { + let node = &nodes[index]; + let sibling = (0..index).rev().find(|&at| { + nodes[at].level <= node.level + && !(nodes[at].level == node.level && skipped.contains(&nodes[at].id)) + })?; + if nodes[sibling].level != node.level || nodes[sibling].parent != node.parent { + return None; + } + let mut members = BTreeSet::from([nodes[sibling].id]); + nodes[sibling + 1..index] + .iter() + .all(|node| { + let inside = skipped.contains(&node.id) + || node.parent.is_some_and(|parent| members.contains(&parent)); + members.insert(node.id); + inside + }) + .then_some(sibling) +} + +/// Tab or Shift+Tab on `range` of `nodes` as OneNote does, moving each paragraph with its +/// subtree: indenting makes a paragraph the last child of its previous sibling, or without one +/// indents it within its group; outdenting a child makes it its parent's sibling, adopting the +/// siblings after it. None when nothing moves. +pub(crate) fn indent( + nodes: &[PageParagraph], + container: Option, + range: Range, + outdent: bool, +) -> Option { + let selected = nodes[range.clone()] + .iter() + .map(|node| node.id) + .collect::>(); + let mut end = range.end; + let mut tops = BTreeMap::new(); + let mut adopters = BTreeMap::new(); + for index in range.clone() { + let node = &nodes[index]; + if node.parent.is_some_and(|parent| selected.contains(&parent)) { + continue; + } + end = end.max(subtree_end(nodes, index)); + if !outdent { + let parent = previous_sibling(nodes, index, &selected).map(|at| nodes[at].id); + tops.insert(node.id, (parent.or(node.parent), 1)); + } else if node.level > 1 { + let parent = node + .parent + .and_then(|id| nodes[..index].iter().rposition(|node| node.id == id)) + .filter(|&at| nodes[at].level + 1 == node.level); + let parent = match parent { + Some(at) => { + end = end.max(subtree_end(nodes, at)); + adopters.insert(nodes[at].id, node.id); + nodes[at].parent + } + None => node.parent, + }; + tops.insert(node.id, (parent, -1)); + } + } + if tops.is_empty() { + return None; + } + let mut shifts = BTreeMap::new(); + let replacement = nodes[range.start..end] + .iter() + .map(|node| { + let mut node = node.clone(); + let shift = match (tops.get(&node.id), node.parent) { + (Some(&(parent, shift)), _) => { + node.parent = parent; + shift + } + (None, Some(parent)) + if !selected.contains(&node.id) && adopters.contains_key(&parent) => + { + node.parent = Some(adopters[&parent]); + 0 + } + (None, parent) => parent + .and_then(|parent| shifts.get(&parent).copied()) + .unwrap_or(0), + }; + node.level = node.level.saturating_add_signed(shift); + shifts.insert(node.id, shift); + node + }) + .collect(); + Some(DocumentEdit { + columns: BTreeMap::new(), + container, + range: range.start..end, + replacement, + }) } pub(crate) fn container_mut( @@ -318,10 +457,6 @@ impl TextDocument { &self.nodes } - pub(crate) fn validate_flat(&self) -> Result<(), EditError> { - validate_flat(self.nodes.iter()) - } - pub fn text_nodes(&self) -> impl Iterator { leaves(&self.nodes, None).map(|(_, _, node)| node) } @@ -452,6 +587,11 @@ impl TextDocument { Ok(result) } + /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps + /// its identity and properties, and a paragraph the replacement adds takes its level, + /// parent, style and lists but no note tags, except that a range's last paragraph stays + /// itself when the replacement ends in one. The last paragraph holds the children of the + /// paragraphs the edit removes or splits. pub(crate) fn replace( &self, range: Range, @@ -469,13 +609,7 @@ impl TextDocument { if container != end_container { return Err(EditError::UnsupportedContent); } - if range.start.paragraph != range.end.paragraph || replacement.len() != 1 { - validate_flat(self.container(container)?.iter().enumerate().filter_map( - |(index, node)| { - ((start..=end).contains(&index) || node.text().is_some()).then_some(node) - }, - ))?; - } + let nodes = self.container(container)?; let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?; let last_text = &last.text().unwrap().text; let suffix = @@ -484,32 +618,90 @@ impl TextDocument { prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?; let mut head = first.clone(); head.text_mut().unwrap().text = prefix; - let mut nodes = vec![head]; let following = replacement.len(); + let keeps_last = start != end && following > 0; + let mut added = Vec::new(); for (index, text) in replacement.enumerate() { - if index + 1 == following && range.start.paragraph != range.end.paragraph { + let mut next = if keeps_last && index + 1 == following { let mut end = last.clone(); end.text_mut().unwrap().text = text; - nodes.push(end); + end } else { let mut next = node(text, first.format.clone())?; - next.level = first.level; next.style = first.style; - nodes.push(next); - } + next.lists.clone_from(&first.lists); + next + }; + next.parent = first.parent; + next.level = first.level; + added.push(next); } + if let Some(tail) = added.last_mut() { + tail.collapsed |= std::mem::take(&mut head.collapsed); + } + let tail = added.last_mut().unwrap_or(&mut head); if !suffix.text().is_empty() { - let end = nodes.last_mut().unwrap(); - end.text_mut().unwrap().text.append(suffix)?; + tail.text_mut().unwrap().text.append(suffix)?; } + let tail = added.last().unwrap_or(&head); + let mut moves = nodes[start + 1..end + usize::from(!keeps_last)] + .iter() + .map(|node| (node.id, tail)) + .collect::>(); + if tail.id != head.id { + moves.insert(head.id, tail); + } + let shifts = keeps_last + .then(|| (last.id, i64::from(first.level) - i64::from(last.level))) + .into_iter() + .collect(); + let adopted = adopt(nodes, end + 1, &moves, shifts)?; Ok(DocumentEdit { columns: BTreeMap::new(), container, - range: start..end + 1, - replacement: nodes, + range: start..end + 1 + adopted.len(), + replacement: [head].into_iter().chain(added).chain(adopted).collect(), }) } + /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties + /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree + /// lies between them in one container. + pub(crate) fn join( + &self, + upper: usize, + lower: usize, + ) -> Result, EditError> { + let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?; + let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?; + let nodes = self.container(container)?; + if container != end_container + || last <= first + || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last) + { + return Ok(None); + } + let mut head = top.clone(); + let text = head.text_mut().unwrap(); + // OneNote moves the lower text object into an emptied upper paragraph. + if text.text.text().is_empty() { + text.id = bottom.text().unwrap().id; + } + text.text.append(bottom.text().unwrap().text.clone())?; + let moves = BTreeMap::from([(bottom.id, &head)]); + let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; + Ok(Some(DocumentEdit { + columns: BTreeMap::new(), + container, + range: first..last + 1 + adopted.len(), + replacement: [head] + .into_iter() + .chain(nodes[first + 1..last].iter().cloned()) + .chain(adopted) + .collect(), + })) + } + /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or /// whose column widths or text are invalid, looking only where the edit can conflict. pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> { @@ -899,13 +1091,21 @@ mod tests { assert_eq!(document.nodes()[1], original.nodes()[1]); document.apply(undo).unwrap(); assert_eq!(document, original); - assert_eq!( - document.replace( + let edit = document + .replace( position(0, 0)..position(4, 0), - vec![Paragraph::new(String::new(), Format::default())] - ), - Err(EditError::UnsupportedContent) + vec![Paragraph::new(String::new(), Format::default())], + ) + .unwrap(); + document.apply(edit).unwrap(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["after"] ); + assert_eq!(document.nodes().len(), 1); } #[test] @@ -1221,7 +1421,7 @@ mod tests { } #[test] - fn editing_nested_tagged_text_preserves_metadata_and_refuses_structural_changes() { + fn editing_nested_tagged_text_preserves_metadata_through_structural_changes() { let mut nodes = TextDocument::new( ["parent", "a🌳e\u{301}z", "child"] .into_iter() @@ -1284,19 +1484,51 @@ mod tests { assert_eq!(document, edited); } } - for (range, count) in [ - (position(1, 0)..position(1, 0), 2), - (position(0, 6)..position(1, 0), 1), - (position(1, 0)..position(2, 0), 1), - ] { - assert_eq!( - original.replace( + let structure = |range: Range, count| { + let mut document = original.clone(); + let edit = document + .replace( range, - vec![Paragraph::new(String::new(), Format::default()); count] - ), - Err(EditError::UnsupportedContent) - ); - } + vec![Paragraph::new(String::new(), Format::default()); count], + ) + .unwrap(); + let undo = document.apply(edit).unwrap(); + let edited = document.nodes().to_vec(); + document.apply(undo).unwrap(); + assert_eq!(document, original); + edited + }; + let [parent, item, child] = original.nodes() else { + unreachable!() + }; + // The new half takes the list, the children and their collapsed state, not the tags. + let split = structure(position(1, 0)..position(1, 0), 2); + assert_eq!(split[1].id, item.id); + assert!(split[1].text().unwrap().text.text().is_empty() && !split[1].collapsed); + assert_eq!(split[1].tags, item.tags); + let tail = &split[2]; + assert_eq!(tail.text().unwrap().text, item.text().unwrap().text); + assert_eq!((tail.parent, tail.level), (Some(parent.id), 2)); + assert_eq!(tail.lists, item.lists); + assert!(tail.tags.is_empty() && tail.text().unwrap().tags.is_empty() && tail.collapsed); + assert_eq!((split[3].parent, split[3].level), (Some(tail.id), 3)); + // The upper paragraph wins a join and adopts the lower one's children. + let joined = structure(position(0, 6)..position(1, 0), 1); + assert_eq!(joined[0].text().unwrap().text.text(), "parenta🌳e\u{301}z"); + assert_eq!((joined[0].id, joined[0].lists.len()), (parent.id, 0)); + assert_eq!( + (joined[1].id, joined[1].parent, joined[1].level), + (child.id, Some(parent.id), 2) + ); + let joined = structure(position(1, 6)..position(2, 0), 1); + assert_eq!(joined.len(), 2); + assert_eq!( + PageParagraph { + content: item.content.clone(), + ..joined[1].clone() + }, + *item + ); } #[test] @@ -1365,7 +1597,7 @@ mod tests { .unwrap(); for (original, regions) in [ (original, vec![0, 0, 0]), - (table_document(), vec![0, 1, 1, 2, 3]), + (table_document(), vec![0, 1, 1, 2, 0]), ] { let positions: Vec<_> = original .paragraphs() diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 44cda7d0629a12db8d922d723fab1f205b8167a2..ca9616bb9be4fe7186b3d9f2d976d59522511451 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -6,6 +6,7 @@ use crate::{ }; use draw::edit::{self, Movement, SelectionUnit}; use onestore::ExGuid; +use onestore::document::{Format, Kind}; use onestore::page::text::{EditError, Paragraph}; use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; use parley::{ @@ -20,6 +21,8 @@ use std::{ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; +#[cfg(test)] +mod evidence; mod format; pub(crate) mod page; mod table; @@ -126,22 +129,20 @@ pub struct TextOutline { impl TextOutline { /// A single plain paragraph containing only ASCII spaces or no text. pub fn is_empty(&self) -> bool { - self.document.nodes().len() == 1 - && self.document.validate_flat().is_ok() - && self - .document - .paragraphs() - .next() - .unwrap() - .text() - .bytes() - .all(|byte| byte == b' ') + let [node] = self.document.nodes() else { + return false; + }; + node.lists.is_empty() + && node.tags.is_empty() + && !node.collapsed + && node.text().is_some_and(|text| { + text.tags.is_empty() && text.text.text().bytes().all(|byte| byte == b' ') + }) } /// Tests an outline-local point against the provisional paragraph band. pub fn contains_extension(&self, point: [f32; 2]) -> bool { !self.title - && self.document.validate_flat().is_ok() && (0.0..=self.shaped.size[0]).contains(&point[0]) && point[1] > self.shaped.size[1] && point[1] <= self.shaped.size[1] + 27.0 @@ -1148,7 +1149,11 @@ impl CanvasEditor { let outline = self.active_outline(); let count = outline.document.nodes().len(); let height = outline.shaped.size[1]; - let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?; + let last = &outline.document.nodes()[count - 1]; + let node = PageParagraph { + parent: last.parent, + ..self.blank_paragraph(last)? + }; self.apply( engine, DocumentEdit { @@ -1864,13 +1869,6 @@ impl CanvasEditor { }; if target.is_none() && !extend && anchor == focus && !self.active_outline().title { let outline = self.active_outline(); - crate::document::validate_flat( - outline - .document - .nodes() - .iter() - .filter(|node| node.text().is_some()), - )?; let last = outline.document.nodes().len() - 1; let up = matches!(movement, Movement::Up); let base_node = &outline.document.nodes()[if up { 0 } else { last }]; @@ -1964,7 +1962,10 @@ impl CanvasEditor { columns: BTreeMap::new(), container: None, range: last + 1..last + 1, - replacement: vec![node], + replacement: vec![PageParagraph { + parent: base_node.parent, + ..node + }], }; let paragraph = outline.document.paragraphs().count(); let previous = if let Focus::Outline(index) = self.active { @@ -2223,12 +2224,127 @@ impl CanvasEditor { self.replace(engine, replacement) } + /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style + /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). + pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + let lines = text + .split('\n') + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .collect::>(); + let last = lines[lines.len() - 1]; + if lines.len() == 1 { + return self.insert(engine, last); + } + let [anchor, focus] = self.active_outline().selection.positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); + let edge = Paragraph::new(String::new(), self.typing_format(start)?); + let pasted = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Format::default() + }; + let mut edit = self.active_outline().document.replace( + start..end, + std::iter::once(edge.clone()) + .chain( + lines + .iter() + .map(|line| Paragraph::new((*line).to_owned(), pasted.clone())), + ) + .chain([edge]) + .collect(), + )?; + for node in &mut edit.replacement[1..=lines.len()] { + node.style = None; + node.lists.clear(); + } + let caret = TextPosition { + paragraph: start.paragraph + lines.len(), + offset: u32::try_from(last.encode_utf16().count()) + .map_err(|_| EditError::TextTooLong)?, + }; + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + ) + } + + /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the + /// level, lists and character formatting at the caret but no note tags. At a paragraph's + /// start its tags stay with its text below; an empty paragraph opens a plain one above and + /// leaves the list it ends; a heading continues as body text. + fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { + let [anchor, focus] = self.active_outline().selection.positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); + let format = self.typing_format(start)?; + let document = &self.active_outline().document; + let mut edit = + document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?; + let nodes = document.container(edit.container)?; + let next = nodes + .get(crate::document::subtree_end(nodes, edit.range.start)) + .filter(|next| { + let node = &nodes[edit.range.start]; + next.parent == node.parent && next.level == node.level + }); + let [head, tail, ..] = &mut edit.replacement[..] else { + unreachable!("a split holds both halves") + }; + let empty = |node: &PageParagraph| node.text().unwrap().text.text().is_empty(); + if start.offset == 0 { + tail.tags = std::mem::take(&mut head.tags); + tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); + } + let style = |id: Option| match &self.definitions.get(&id?)?.kind { + Kind::Style { name } => name.as_deref(), + _ => None, + }; + if empty(head) && empty(tail) { + head.lists.clear(); + if next.is_none_or(|next| next.lists.is_empty()) { + tail.lists.clear(); + } + } else if empty(tail) + && matches!( + style(head.style), + Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6") + ) + { + tail.style = self + .definitions + .keys() + .copied() + .find(|id| style(Some(*id)) == Some("p")); + tail.text_mut().unwrap().text = + Paragraph::new(String::new(), self.style_format(tail.style)?); + } + let caret = TextPosition { + paragraph: start.paragraph + 1, + offset: 0, + }; + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + ) + } + + /// Tab or Shift+Tab at the start of the selected paragraphs; see [`crate::document::indent`]. + /// A default bullet or number steps to the style OneNote gives its new depth. pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result { let outline = self.active_outline(); if outline.title { return Ok(false); } - let [anchor, focus] = outline.selection.positions; + let selection = outline.selection; + let [anchor, focus] = selection.positions; let start = anchor.min(focus); let end = anchor.max(focus); let (container, local_start, _) = outline @@ -2245,96 +2361,111 @@ impl CanvasEditor { let range = local_start ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph); let nodes = outline.document.container(container)?; - // Lists and tags move with their paragraphs; parent links and collapsed children would not. - if nodes.iter().enumerate().any(|(index, node)| { - (range.contains(&index) || node.text().is_some()) - && (node.parent.is_some() || node.collapsed) - }) { - return Err(EditError::UnsupportedContent.into()); - } - let nodes = &nodes[range.clone()]; - if outdent && nodes.iter().all(|node| node.level == 1) { + let Some(mut edit) = crate::document::indent(nodes, container, range, outdent) else { return Ok(false); - } - let replacement = nodes + }; + let levels = nodes[edit.range.clone()] .iter() - .map(|node| { - let mut node = node.clone(); - node.level = if outdent { - node.level.saturating_sub(1).max(1) - } else { - node.level - .checked_add(1) - .ok_or(EditError::InvalidStructure)? + .map(|node| node.level) + .collect::>(); + for (node, level) in edit.replacement.iter_mut().zip(levels) { + if node.level == level { + continue; + } + for list in &mut node.lists { + let Some(definition) = self + .definitions + .get(list) + .and_then(|definition| format::nested_list(definition, node.level > level)) + else { + continue; }; - Ok(node) - }) - .collect::, EditError>>()?; - self.commit( - engine, - DocumentEdit { - columns: BTreeMap::new(), - container, - range, - replacement, - }, - outline.selection, - )?; + *list = onestore::page::text::new_id()?; + self.definitions.insert(*list, definition); + } + } + self.commit(engine, edit, selection)?; Ok(true) } + /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start + /// first removes its list, then outdents it, then joins it to the paragraph above, whose + /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/ + /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children. pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result { - let [anchor, focus] = self.active_outline().selection.positions; + let selection = self.active_outline().selection; + let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); if range.is_empty() { - let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; - let cursor = - paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; - if let Some(cluster) = + let outline = self.active_outline(); + let paragraph = outline.paragraph_layout(focus.paragraph)?; + let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?; + let Some(cluster) = cursor.logical_clusters(¶graph.text.shaped)[usize::from(!backward)] - { - let visible = paragraph.projection.text(); - let bytes = cluster.text_range(); - range.start.offset = paragraph.projection.source_offset( - visible.utf16_offset(bytes.start)?, - onestore::page::text::Affinity::Downstream, - )?; - range.end.offset = paragraph.projection.source_offset( - visible.utf16_offset(bytes.end)?, - onestore::page::text::Affinity::Upstream, - )?; - } else { - let document = &self.active_outline().document; - let Some(neighbor) = (if backward { - focus.paragraph.checked_sub(1) - } else { - focus.paragraph.checked_add(1) - }) else { - return Ok(false); - }; - let (container, local, current) = document + else { + let (container, local, node) = outline + .document .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; - let Some((next_container, next_local, next)) = document.leaf(neighbor) else { + if backward && !node.lists.is_empty() { + let item = PageParagraph { + lists: Vec::new(), + ..node.clone() + }; + let edit = DocumentEdit { + columns: BTreeMap::new(), + container, + range: local..local + 1, + replacement: vec![item], + }; + self.commit(engine, edit, selection)?; + return Ok(true); + } + if backward && node.level > 1 { + return self.indent(engine, true); + } + let visible = outline.visible_index(focus.paragraph)?; + let Some(neighbor) = (if backward { + visible.checked_sub(1) + } else { + Some(visible + 1).filter(|next| *next < outline.shaped.paragraphs.len()) + }) else { return Ok(false); }; - if container != next_container || local.abs_diff(next_local) != 1 { - return Ok(false); - } - let (previous, start, end) = if backward { - (next, neighbor, focus.paragraph) + let neighbor = outline.source_index(neighbor); + let [upper, lower] = if backward { + [neighbor, focus.paragraph] } else { - (current, focus.paragraph, neighbor) + [focus.paragraph, neighbor] + }; + let Some(edit) = outline.document.join(upper, lower)? else { + return Ok(false); }; - let text = &previous.text().unwrap().text; - range = TextPosition { - paragraph: start, + let text = outline.document.paragraph(upper).unwrap(); + let caret = TextPosition { + paragraph: upper, offset: text.utf16_offset(text.text().len())?, - }..TextPosition { - paragraph: end, - offset: 0, }; - } + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + )?; + return Ok(true); + }; + let visible = paragraph.projection.text(); + let bytes = cluster.text_range(); + range.start.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.start)?, + onestore::page::text::Affinity::Downstream, + )?; + range.end.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.end)?, + onestore::page::text::Affinity::Upstream, + )?; } self.delete_range(engine, range) } @@ -2748,10 +2879,11 @@ impl CanvasEditor { .split('\n') .map(|part| Paragraph::new(part.to_owned(), format.clone())) .collect(); - let edit = self + let mut edit = self .active_outline() .document .replace(range.clone(), replacement)?; + self.own_lists(&mut edit)?; let inverse = self.apply( engine, edit, @@ -2764,6 +2896,12 @@ impl CanvasEditor { )?; let original = if let Some(composition) = self.composition.take() { let mut original = composition.original; + // Nodes this edit reaches past what the composition changed are still as they were. + let changed = original.edit.range.len(); + original + .edit + .replacement + .extend(inverse.edit.replacement.iter().skip(changed).cloned()); original.edit.range = inverse.edit.range; for (id, widths) in inverse.edit.columns { original.edit.columns.entry(id).or_insert(widths); @@ -2832,14 +2970,40 @@ impl CanvasEditor { fn commit( &mut self, engine: &mut TextEngine, - edit: DocumentEdit, + mut edit: DocumentEdit, selection: Selection, ) -> Result<(), EditorError> { + self.own_lists(&mut edit)?; let inverse = self.apply(engine, edit, selection, true, None)?; self.record_change(inverse); Ok(()) } + /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps + /// a list node per paragraph. + fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> { + let existing = self.active_outline().document.container(edit.container)? + [edit.range.clone()] + .iter() + .map(|node| node.id) + .collect::>(); + for node in &mut edit.replacement { + if existing.contains(&node.id) { + continue; + } + for list in &mut node.lists { + let definition = self + .definitions + .get(list) + .ok_or(EditError::InvalidStructure)? + .clone(); + *list = onestore::page::text::new_id()?; + self.definitions.insert(*list, definition); + } + } + Ok(()) + } + fn record_change(&mut self, change: TextChange) { self.pending = None; if let Focus::Draft { index, .. } = self.active { @@ -5553,6 +5717,9 @@ mod tests { .collect::>(), [2, 2, 1] ); + // Backspace outdents an indented paragraph before joining it (`c4-child-1.xml`). + editor.delete(&mut engine, true).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[1].level, 1); editor.delete(&mut engine, true).unwrap(); assert_eq!( editor.active_outline().document.nodes()[0] @@ -5562,9 +5729,9 @@ mod tests { .text(), "alpha beta gamma delta" ); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); + for _ in 0..4 { + editor.undo(&mut engine).unwrap(); + } assert_eq!(editor.active_outline().document, source); assert_eq!(editor.selection(), selection); assert!(!editor.indent(&mut engine, true).unwrap()); @@ -6505,7 +6672,16 @@ mod tests { let edited = layout_snapshot(&editor.active_outline().shaped); let before = editor.active_outline().document.clone(); let selection = editor.selection(); - assert!(editor.insert(&mut engine, "\n").is_err()); + editor.enter(&mut engine, false).unwrap(); + let split = &editor.active_outline().document.nodes()[2]; + assert_eq!((split.parent, split.level), (Some(nodes[0].id), 2)); + assert_ne!(split.lists, [marker]); + assert_eq!( + editor.definitions[&split.lists[0]], + editor.definitions[&marker] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, before); assert!(editor.resize(&mut engine, 20.0).is_err()); assert_eq!(editor.active_outline().document, before); assert_eq!(editor.selection(), selection); @@ -6555,6 +6731,75 @@ mod tests { assert!(!editor.selection_rects().unwrap().is_empty()); } + #[test] + fn joins_pass_over_hidden_children_and_a_split_hands_them_on_still_collapsed() { + let mut engine = TextEngine::default(); + let mut nodes = TextDocument::new( + ["Collapsed", "Hidden", "After"] + .into_iter() + .map(|text| Paragraph::new(text.into(), Format::default())) + .collect(), + ) + .unwrap() + .nodes() + .to_vec(); + nodes[0].collapsed = true; + nodes[1].parent = Some(nodes[0].id); + nodes[1].level = 2; + let outline = Outline { + paragraphs: nodes.clone(), + ..TextOutline::new( + &mut engine, + TextDocument::from_nodes(nodes.clone()).unwrap(), + 300.0, + [0.0; 2], + ) + .unwrap() + .snapshot() + }; + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap(); + let texts = |editor: &CanvasEditor| { + editor + .active_outline() + .document + .paragraphs() + .map(|text| text.text().to_owned()) + .collect::>() + }; + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + editor.select(at(2, 0)).unwrap(); + assert!(editor.delete(&mut engine, true).unwrap()); + assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 0, + offset: 9 + }; 2] + ); + editor.undo(&mut engine).unwrap(); + editor.select(at(0, 9)).unwrap(); + assert!(editor.delete(&mut engine, false).unwrap()); + assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]); + editor.undo(&mut engine).unwrap(); + editor.select(at(0, 9)).unwrap(); + editor.enter(&mut engine, false).unwrap(); + let split = editor.active_outline().document.nodes(); + assert!(!split[0].collapsed && split[1].collapsed); + assert_eq!(split[2].parent, Some(split[1].id)); + assert_eq!( + editor + .active_outline() + .layouts() + .map(|(index, _)| index) + .collect::>(), + [0, 1, 3] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document.nodes(), nodes); + } + #[test] #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] fn imported_editor_reflows_and_restores_native_outline_geometry() { @@ -7934,9 +8179,13 @@ mod tests { .outlines .iter() .find(|outline| { + let [node] = outline.document.nodes() else { + return false; + }; !outline.title - && outline.document.nodes().len() == 1 - && outline.document.validate_flat().is_ok() + && node.lists.is_empty() + && node.tags.is_empty() + && node.text().is_some_and(|text| text.tags.is_empty()) }) .unwrap() .id; diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs new file mode 100644 index 0000000000000000000000000000000000000000..e257b26eeef0450fd5f2b0e518cb3e94f7b2c96b --- /dev/null +++ b/crates/canvas/src/editor/evidence.rs @@ -0,0 +1,765 @@ +//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it +//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and +//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts +//! paragraph nesting. + +use super::format::{NoteTag, list_definition}; +use super::*; +use onestore::document::Tag; +use onestore::page::ParagraphContent; +use onestore::page::text::new_id; + +fn summary(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/../../evidence/structural-edits/summaries/{name}.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + .replace("\r\n", "\n") +} + +fn calibri(size: f32, bold: bool) -> Format { + Format { + font: Some("Calibri".into()), + font_size: Some(size), + bold: Some(bold), + ..Format::default() + } +} + +/// The quick styles a summary's `qs0` and `qs1` name. +const STYLES: [&str; 2] = ["p", "h1"]; + +/// An editor holding one outline shaped as a summary describes it. +fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { + let mut definitions = BTreeMap::new(); + let mut ids = BTreeMap::new(); + let mut define = |key: String, definition: Definition| { + *ids.entry(key).or_insert_with(|| { + let id = new_id().unwrap(); + definitions.insert(id, definition); + id + }) + }; + let styles = STYLES.map(|name| { + define( + name.into(), + Definition { + kind: Kind::Style { + name: Some(name.into()), + }, + format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"), + }, + ) + }); + let mut nodes: Vec = Vec::new(); + for line in summary.lines() { + let line = line.trim_start().strip_prefix('L').unwrap(); + let (level, line) = line.split_once(" [").unwrap(); + let level = level.parse::().unwrap(); + let (tags, line) = line.split_once("] [").unwrap(); + let (list, line) = line.split_once("] ").unwrap(); + let (style, text) = line.split_once(' ').unwrap(); + let style = STYLES + .iter() + .position(|name| style == format!("qs{}", usize::from(*name == "h1"))) + .map(|index| styles[index]); + let format = calibri(11.0, false); + let mut node = + crate::document::node(Paragraph::new(text.into(), format.clone()), format.clone()) + .unwrap(); + node.level = level; + node.style = style; + node.parent = nodes + .iter() + .rev() + .find(|parent| parent.level + 1 == level) + .map(|parent| parent.id); + if !list.is_empty() { + let definition = list_definition(list.starts_with("num"), &format); + node.lists = vec![define(format!("list {}", nodes.len()), definition)]; + } + for tag in tags.split(',').filter(|tag| !tag.is_empty()) { + let (label, completed) = match tag.strip_suffix("[x]") { + Some(label) => (label, true), + None => (tag, false), + }; + let tag = NoteTag::ALL + .into_iter() + .find(|tag| { + matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label) + }) + .unwrap(); + let definition = define(label.into(), tag.definition()); + node.text_mut().unwrap().tags.push(Tag { + definition: Some(definition), + action_type: None, + status: u16::from(completed), + created: Some(1), + completed: Some(u32::from(completed)), + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + } + nodes.push(node); + } + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + max_width: Some(400.0), + ..Default::default() + }, + indents: vec![18.0, 0.0, 27.0, 27.0], + paragraphs: nodes, + unsupported: Vec::new(), + }; + CanvasEditor::from_outlines(engine, vec![outline], definitions).unwrap() +} + +/// The outline in the summaries' form. +fn render(editor: &CanvasEditor) -> String { + let outline = editor.active_outline(); + let mut depths = BTreeMap::new(); + let mut lines = Vec::new(); + for node in outline.document.nodes() { + let depth = node.parent.map_or(1, |parent| depths[&parent] + 1); + depths.insert(node.id, depth); + let tags = node + .tags + .iter() + .chain(&node.text().unwrap().tags) + .map(|tag| { + let kind = &editor.definitions[&tag.definition.unwrap()].kind; + let Kind::TagDefinition { label, .. } = kind else { + panic!("{kind:?}") + }; + let checked = if tag.status & 1 == 1 { "[x]" } else { "" }; + ( + NoteTag::ALL + .iter() + .position(|known| known.definition().kind == *kind), + format!("{}{checked}", label.as_deref().unwrap()), + ) + }) + .collect::>(); + let list = match node.lists.first().map(|id| &editor.definitions[id].kind) { + None => String::new(), + Some(Kind::List { + bullet: Some(_), .. + }) => "bullet".into(), + Some(Kind::List { + format: Some(format), + .. + }) => { + let (_, rest) = format.split_once('\u{fffd}').unwrap(); + let mut rest = rest.chars(); + let number = outline + .shaped + .paragraphs + .iter() + .find(|paragraph| paragraph.id == node.id) + .and_then(|paragraph| paragraph.number) + .unwrap() + .0; + let numeral = crate::outline::numeral(rest.next(), number).unwrap(); + format!("num:{numeral}{}", rest.as_str()) + } + Some(kind) => panic!("{kind:?}"), + }; + let style = node + .style + .map(|style| match &editor.definitions[&style].kind { + Kind::Style { name } => { + format!("qs{}", usize::from(name.as_deref() == Some("h1"))) + } + kind => panic!("{kind:?}"), + }) + .unwrap_or_default(); + lines.push(format!( + "{}L{depth} [{}] [{list}] {style} {}", + " ".repeat(depth as usize), + tags.into_values().collect::>().join(","), + node.text().unwrap().text.text() + )); + } + lines.join("\n").trim_end().to_owned() +} + +#[derive(Clone, Copy)] +enum Offset { + Start, + End, + /// Characters before the end. + Back(u32), + /// Characters after the start. + Forward(u32), +} + +#[derive(Clone, Copy)] +enum Key { + Enter, + Backspace, + Delete, + Tab, + ShiftTab, + Type(&'static str), + Paste(&'static str), + Bullets, +} + +/// Puts the caret at `offset` in paragraph `paragraph`. +fn place(editor: &mut CanvasEditor, (paragraph, offset): (usize, Offset)) { + let text = editor + .active_outline() + .document + .paragraph(paragraph) + .unwrap(); + let length = text.text().encode_utf16().count() as u32; + let offset = match offset { + Offset::Start => 0, + Offset::End => length, + Offset::Back(count) => length - count, + Offset::Forward(count) => count, + }; + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); +} + +fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) { + match key { + Key::Enter => editor.enter(engine, false).unwrap(), + Key::Backspace => assert!(editor.delete(engine, true).unwrap()), + Key::Delete => assert!(editor.delete(engine, false).unwrap()), + Key::Tab => editor.tab(engine, false).unwrap(), + Key::ShiftTab => editor.tab(engine, true).unwrap(), + Key::Type(text) => editor.insert(engine, text).unwrap(), + Key::Paste(text) => editor.paste(engine, text).unwrap(), + Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(), + } +} + +/// Replays `steps` from `caret` in `before`, comparing the outline after each key with the +/// summary it names; returns the editor for further checks. +fn replay( + engine: &mut TextEngine, + before: &str, + caret: (usize, Offset), + steps: &[(Key, &str)], +) -> CanvasEditor { + let mut editor = open(engine, before); + place(&mut editor, caret); + let original = editor.active_outline().document.clone(); + for (key, after) in steps { + press(&mut editor, engine, *key); + assert_eq!(render(&editor), summary(after).trim_end(), "{after}"); + let outline = editor.active_outline(); + let fresh = OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + outline.wrap_width(), + true, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions) + }, + ) + .unwrap(); + let numbers = |layout: &OutlineLayout| { + layout + .paragraphs + .iter() + .map(|paragraph| paragraph.number) + .collect::>() + }; + assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}"); + } + let edited = editor.active_outline().document.clone(); + while editor.undo(engine).unwrap() {} + assert_eq!(editor.active_outline().document, original); + while editor.redo(engine).unwrap() {} + assert_eq!(editor.active_outline().document, edited); + editor +} + +/// The run that recorded Enter at the end of a paragraph of `kind`. +fn first_run(kind: &str) -> &'static str { + match kind { + "num" | "child" | "tagbullet" => "c1b", + _ => "c1", + } +} + +/// The setup every single-paragraph case starts from: `Above`, then `Target text` of `kind`. +fn target(kind: &str) -> String { + summary(&format!("{}-{kind}-0", first_run(kind))) +} + +const KINDS: [&str; 9] = [ + "todo", + "done", + "imp", + "q", + "rem", + "bullet", + "num", + "child", + "tagbullet", +]; + +#[test] +fn enter_carries_level_lists_and_children_but_never_tags() { + let mut engine = TextEngine::default(); + for kind in KINDS { + let prefix = first_run(kind); + replay( + &mut engine, + &target(kind), + (1, Offset::End), + &[ + (Key::Enter, &format!("{prefix}-{kind}-1")), + (Key::Type("New"), &format!("{prefix}-{kind}-2")), + ], + ); + replay( + &mut engine, + &target(kind), + (1, Offset::Back(4)), + &[ + (Key::Enter, &format!("c2-{kind}-1")), + (Key::Type("X"), &format!("c2-{kind}-2")), + ], + ); + replay( + &mut engine, + &target(kind), + (1, Offset::Start), + &[ + (Key::Enter, &format!("c2s-{kind}-1")), + (Key::Type("X"), &format!("c2s-{kind}-2")), + ], + ); + replay( + &mut engine, + &summary(&format!("c3-{kind}-0")), + (2, Offset::Start), + &[ + (Key::Enter, &format!("c3-{kind}-1")), + (Key::Enter, &format!("c3-{kind}-2")), + (Key::Type("New"), &format!("c3-{kind}-3")), + ], + ); + } + replay( + &mut engine, + &summary("pbe-0"), + (2, Offset::Start), + &[(Key::Enter, "pbe-1")], + ); + replay( + &mut engine, + &summary("c8-enter-empty-0"), + (1, Offset::End), + &[ + (Key::Enter, "c8-enter-empty-1"), + (Key::Enter, "c8-enter-empty-2"), + ], + ); + replay( + &mut engine, + &summary("c8-split-0"), + (2, Offset::Forward(1)), + &[(Key::Enter, "c8-split-1")], + ); + replay( + &mut engine, + &summary("c10-enter-end-0"), + (1, Offset::End), + &[(Key::Enter, "c10-enter-end-1")], + ); +} + +#[test] +fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() { + let mut engine = TextEngine::default(); + for kind in KINDS { + let steps = ["-1", "-2", "-3"].map(|step| format!("c4-{kind}{step}")); + replay( + &mut engine, + &summary(&format!("c4-{kind}-0")), + (2, Offset::Start), + &steps.each_ref().map(|step| (Key::Backspace, step.as_str())), + ); + } + for pair in [ + "imp-todo", + "plain-todo", + "todo-plain", + "todo-done", + "done-todo", + "q-rem", + ] { + replay( + &mut engine, + &summary(&format!("c4m-{pair}-0")), + (1, Offset::Start), + &[(Key::Backspace, &format!("c4m-{pair}-1"))], + ); + } + replay( + &mut engine, + &summary("c4m-emptybullet-0"), + (2, Offset::Start), + &[ + (Key::Backspace, "c4m-emptybullet-1"), + (Key::Backspace, "c4m-emptybullet-2"), + (Key::Backspace, "c4m-emptybullet-3"), + ], + ); + replay( + &mut engine, + &summary("c4m-emptytag-0"), + (1, Offset::Start), + &[ + (Key::Backspace, "c4m-emptytag-1"), + (Key::Backspace, "c4m-emptytag-2"), + ], + ); + replay( + &mut engine, + &summary("c8-bs-0"), + (2, Offset::Start), + &[(Key::Backspace, "c8-bs-1"), (Key::Backspace, "c8-bs-2")], + ); + replay( + &mut engine, + &summary("c10-bs-parent-0"), + (1, Offset::Start), + &[(Key::Backspace, "c10-bs-parent-1")], + ); +} + +#[test] +fn delete_at_the_end_joins_the_next_paragraph_into_this_one() { + let mut engine = TextEngine::default(); + for (pair, paragraph) in [ + ("imp-todo", 0), + ("plain-todo", 0), + ("todo-plain", 0), + ("done-todo", 0), + ("plain-bullet", 0), + ("plain-num", 0), + ("bullet-plain", 1), + ("bullet-bullet", 1), + ] { + replay( + &mut engine, + &summary(&format!("c5b-{pair}-0")), + (paragraph, Offset::End), + &[(Key::Delete, &format!("c5b-{pair}-1"))], + ); + } + replay( + &mut engine, + &summary("c8-del-0"), + (1, Offset::End), + &[(Key::Delete, "c8-del-1")], + ); + for (name, paragraph) in [("del-alpha", 0), ("del-parent", 1)] { + replay( + &mut engine, + &summary(&format!("c10-{name}-0")), + (paragraph, Offset::End), + &[(Key::Delete, &format!("c10-{name}-1"))], + ); + } +} + +/// The list definition of the outline's paragraph `index`. +fn list(editor: &CanvasEditor, index: usize) -> &Kind<'static> { + let node = &editor.active_outline().document.nodes()[index]; + &editor.definitions[&node.lists[0]].kind +} + +#[test] +fn tab_nests_under_the_previous_sibling_and_shift_tab_adopts_the_siblings_after() { + let mut engine = TextEngine::default(); + for kind in ["plain", "todo", "bullet", "num", "child", "tagbullet"] { + let editor = replay( + &mut engine, + &summary(&format!("c6-{kind}-0")), + (1, Offset::Start), + &[(Key::Tab, &format!("c6-{kind}-tab"))], + ); + // Without a previous sibling OneNote indents within the group (`` + // in `c6-{bullet,num,child,tagbullet}-tab.xml`), stepping bullets to â—‹ and numbers to a. + let first_child = matches!(kind, "bullet" | "num" | "child" | "tagbullet"); + let node = &editor.active_outline().document.nodes()[1]; + assert_eq!(node.level, if first_child { 3 } else { 2 }, "{kind}"); + if matches!(kind, "bullet" | "tagbullet") { + assert!(matches!( + list(&editor, 1), + Kind::List { format: Some(glyph), bullet: Some(4), .. } if glyph == "\u{25cb}" + )); + } + replay( + &mut engine, + &summary(&format!("c6-{kind}-0")), + (1, Offset::Start), + &[ + (Key::Tab, &format!("c6-{kind}-tab")), + (Key::ShiftTab, &format!("c6-{kind}-stab1")), + (Key::ShiftTab, &format!("c6-{kind}-stab2")), + ], + ); + } + let first = replay( + &mut engine, + " L1 [] [] qs0 Target text", + (0, Offset::Start), + &[(Key::Tab, "c6-first-tab")], + ); + assert_eq!( + ( + first.active_outline().document.nodes()[0].level, + first.active_outline().document.nodes()[0].parent + ), + (2, None) + ); + replay( + &mut engine, + &summary("c8-stab-0"), + (2, Offset::Start), + &[(Key::ShiftTab, "c8-stab-1")], + ); + replay( + &mut engine, + &summary("c8-tab-0"), + (2, Offset::Start), + &[(Key::Tab, "c8-tab-1"), (Key::ShiftTab, "c8-tab-2")], + ); + replay( + &mut engine, + &summary("c10-stab-child-0"), + (2, Offset::Start), + &[(Key::ShiftTab, "c10-stab-child-1")], + ); +} + +#[test] +fn pasted_lines_are_plain_paragraphs_between_the_halves() { + let mut engine = TextEngine::default(); + for kind in ["todo", "done", "bullet", "num", "tagbullet", "child"] { + // The native run's clipboard held a stray backquote for the numbered case. + let text = if kind == "num" { + "Line one\r\nLine two`" + } else { + "Line one\r\nLine two" + }; + let editor = replay( + &mut engine, + &target(kind), + (1, Offset::Back(4)), + &[(Key::Paste(text), &format!("c7-{kind}-1"))], + ); + let document = &editor.active_outline().document; + let format = Format { + bold: None, + ..calibri(11.0, false) + }; + assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format); + let last = document.paragraph(3).unwrap().text(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: 3, + offset: last.len() as u32 + } + ); + } + replay( + &mut engine, + " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second", + (1, Offset::Back(1)), + &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")], + ); +} + +#[test] +fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() { + let mut engine = TextEngine::default(); + replay( + &mut engine, + &summary("pb-0"), + (1, Offset::Start), + &[(Key::Bullets, "pb-1"), (Key::Bullets, "pb-2")], + ); +} + +#[test] +fn a_heading_continues_as_body_text_unless_split() { + let mut engine = TextEngine::default(); + // `c9-h1-1.xml` and `c9-h1-2.xml`; their summaries also carry the heading's inline runs. + let mut editor = open(&mut engine, " L1 [] [] qs1 Heading"); + let end = TextPosition { + paragraph: 0, + offset: 7, + }; + editor.select([end; 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + editor.insert(&mut engine, "body").unwrap(); + assert_eq!( + render(&editor), + " L1 [] [] qs1 Heading\n L1 [] [] qs0 body" + ); + let body = editor.active_outline().document.paragraph(1).unwrap(); + assert_eq!(body.spans()[0].format, calibri(11.0, false)); + let middle = TextPosition { + paragraph: 0, + offset: 4, + }; + editor.select([middle; 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!( + render(&editor), + " L1 [] [] qs1 Head\n L1 [] [] qs1 ing\n L1 [] [] qs0 body" + ); +} + +#[test] +fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() { + let mut engine = TextEngine::default(); + // `c9-multitag-{1,2,3}.xml`. + let mut editor = open( + &mut engine, + " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two tags", + ); + let at = |offset| TextPosition { + paragraph: 1, + offset, + }; + editor.select([at(4); 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-1").trim_end()); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-2").trim_end()); + editor.select([at(0); 2].into()).unwrap(); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-3").trim_end()); +} + +/// Nesting, adopted siblings, restyled and removed lists, an outdent, a tagged split and a join +/// that hands on children, on pages OneNote wrote, read back from the page writer unchanged. +#[test] +fn structural_edits_on_native_pages_survive_the_page_writer() { + use onestore::{RevisionIndex, Store, document::Document}; + let section = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../evidence/structural-edits/probe-section/probe.one" + )) + .unwrap(); + let page = |bytes: &[u8], title: &str| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document + .pages() + .unwrap() + .into_iter() + .find(|(space, _)| Page::from_space(&document, *space).unwrap().title == title) + .unwrap(); + (space, Page::from_space(&document, space).unwrap()) + }; + // Tags keep their place in the element's property arena only once read. + let settled = |mut page: Page| { + for object in &mut page.objects { + if let PageObject::Outline(outline) = object { + for paragraph in &mut outline.paragraphs { + for tag in &mut paragraph.text_mut().unwrap().tags { + tag.extra_set = 0; + } + } + } + } + page + }; + let mut engine = TextEngine::default(); + for (title, keys) in [ + ( + "c8-tab", + &[ + ((2, Offset::Start), Key::ShiftTab), + ((4, Offset::Start), Key::Tab), + ((3, Offset::Start), Key::Backspace), + ][..], + ), + ( + "c4-tagbullet", + &[ + ((1, Offset::Start), Key::Backspace), + ((1, Offset::Start), Key::Backspace), + ], + ), + ("c2-todo", &[((1, Offset::Forward(3)), Key::Enter)]), + ( + "c10-stab-child", + &[ + ((2, Offset::Start), Key::Tab), + ((3, Offset::Start), Key::Tab), + ((1, Offset::End), Key::Delete), + ], + ), + ] { + let (space, source) = page(§ion, title); + let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + for (caret, key) in keys { + place(&mut editor, *caret); + press(&mut editor, &mut engine, *key); + } + let edited = settled(editor.page().unwrap()); + let written = onestore::PreparedEdit::page(§ion, space, &edited, "Author") + .unwrap_or_else(|error| panic!("{title}: {error}")); + let (_, reread) = page(written.as_bytes(), title); + assert_eq!(settled(reread).objects, edited.objects, "{title}"); + } +} + +#[test] +fn tab_inside_text_makes_a_table_whose_paragraph_keeps_the_list_tags_and_children() { + let mut engine = TextEngine::default(); + // `c6-tagbullet-midtab.xml`: the table's paragraph holds the tag and bullet, its cells none. + let mut editor = open(&mut engine, &summary("c6-tagbullet-stab2")); + place(&mut editor, (1, Offset::Back(4))); + press(&mut editor, &mut engine, Key::Tab); + let nodes = editor.active_outline().document.nodes(); + let ParagraphContent::Table(table) = &nodes[1].content else { + panic!("{:?}", nodes[1].content) + }; + assert_eq!( + (nodes[1].level, nodes[1].lists.len(), nodes[1].tags.len()), + (1, 1, 1) + ); + for cell in table.rows.iter().flat_map(|row| &row.cells) { + let text = cell.paragraphs[0].text().unwrap(); + assert!(cell.paragraphs[0].lists.is_empty() && text.tags.is_empty()); + } + // `c10-tab-parent-1.xml`: the children stay under the table's paragraph. + let mut editor = open(&mut engine, &summary("c10-tab-parent-0")); + place(&mut editor, (1, Offset::End)); + press(&mut editor, &mut engine, Key::Tab); + let nodes = editor.active_outline().document.nodes(); + assert!(matches!(nodes[1].content, ParagraphContent::Table(_))); + assert_eq!((nodes[2].parent, nodes[2].level), (Some(nodes[1].id), 2)); +} diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 7fa16adbe80b63b1eeec2aba3727f9470645698b..0f6b08ba1146d4611dd3d96c556f5f009e1bbbd6 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -52,13 +52,19 @@ pub enum Alignment { Right, } -/// OneNote 2010's default tags whose stored definition local evidence shows: To Do from a -/// native Ctrl+1 (`corpus/paragraph-edit/reconciliation/keyboard`), Question from notebooks -/// OneNote tagged (`corpus/private/exact-native`). +/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions +/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NoteTag { ToDo, + Important, Question, + RememberForLater, + Definition, + Highlight, + Contact, + Address, + PhoneNumber, } /// What the selection shows on the toolbar. @@ -112,20 +118,38 @@ impl Toggle { } impl NoteTag { - const ALL: [Self; 2] = [Self::ToDo, Self::Question]; + pub(super) const ALL: [Self; 9] = [ + Self::ToDo, + Self::Important, + Self::Question, + Self::RememberForLater, + Self::Definition, + Self::Highlight, + Self::Contact, + Self::Address, + Self::PhoneNumber, + ]; - fn definition(self) -> Definition { - let (label, action_type, shape) = match self { - Self::ToDo => ("To Do", 0, CHECKBOX), - Self::Question => ("Question", 2, 15), + pub(super) fn definition(self) -> Definition { + let (label, shape, highlight) = match self { + Self::ToDo => ("To Do", CHECKBOX, None), + Self::Important => ("Important", 13, None), + Self::Question => ("Question", 15, None), + Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)), + Self::Definition => ("Definition", 0, Some(0x0000_ff00)), + Self::Highlight => ("Highlight", 136, None), + Self::Contact => ("Contact", 118, None), + Self::Address => ("Address", 23, None), + Self::PhoneNumber => ("Phone number", 18, None), }; Definition { kind: Kind::TagDefinition { label: Some(label.into()), - action_type: Some(action_type), + action_type: Some(self as u16), shape: Some(shape), - color: None, - highlight: None, + // The highlighting tags also set black text. + color: highlight.map(|_| 0), + highlight, }, format: Format::default(), } @@ -391,7 +415,7 @@ impl CanvasEditor { ) -> Result<(), EditorError> { let outline = self.active_outline(); let (id, title, selection) = (outline.id, outline.title, outline.selection); - let (container, range, ends) = selected(&outline.document, selection)?; + let (container, mut range, ends) = selected(&outline.document, selection)?; let mut replacement = outline.document.container(container)?[range.clone()].to_vec(); let mut ranges = covered(&replacement, ends) .map(|(node, range)| (node.id, range)) @@ -434,6 +458,18 @@ impl CanvasEditor { Formatting::Bullets | Formatting::Numbering => { let remove = covered(&replacement, ends) .all(|(node, _)| self.list(node).as_ref() == Some(&command)); + // A list applied after a plain sibling nests under it, as Tab would + // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there. + let nodes = outline.document.container(container)?; + if !remove + && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new()) + .is_some_and(|sibling| nodes[sibling].lists.is_empty()) + && let Some(edit) = + crate::document::indent(nodes, container, range.clone(), false) + { + range = edit.range; + replacement = edit.replacement; + } let wanted = covered(&replacement, ends) .map(|(node, _)| node) .filter(|node| !remove && self.list(node).as_ref() != Some(&command)) @@ -632,10 +668,73 @@ impl CanvasEditor { } } +/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote +/// 2010 steps • to â—‹ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, +/// `c8-tab-1.xml`), then to â–  as `corpus/private` nests bullets, and to i.; outdenting stops at +/// the first style. +pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option { + const BULLETS: [(&str, &str, u16); 3] = [ + ("Calibri", "\u{2022}", 1), + ("Courier New", "\u{25cb}", 4), + ("Wingdings", "\u{a7}", 7), + ]; + const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}']; + let Kind::List { + font, + format: Some(format), + restart, + bullet, + } = &definition.kind + else { + return None; + }; + let step = |index: usize| { + if deeper { + Some((index + 1) % 3) + } else { + index.checked_sub(1) + } + }; + let kind = match bullet { + Some(_) => { + let index = BULLETS.iter().position(|(name, glyph, index)| { + (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index)) + })?; + let (name, glyph, index) = BULLETS[step(index)?]; + Kind::List { + font: Some(name.into()), + format: Some(glyph.into()), + restart: *restart, + bullet: Some(index), + } + } + None => { + let (prefix, rest) = format.split_once('\u{fffd}')?; + let mut rest = rest.chars(); + let sequence = rest.next()?; + let index = SEQUENCES.iter().position(|known| *known == sequence)?; + Kind::List { + font: font.clone(), + format: Some(format!( + "{prefix}\u{fffd}{}{}", + SEQUENCES[step(index)?], + rest.as_str() + )), + restart: *restart, + bullet: None, + } + } + }; + Some(Definition { + kind, + format: definition.format.clone(), + }) +} + /// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet /// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores /// from its COM interface (`corpus/outline-edit/tree`). -fn list_definition(numbering: bool, format: &Format) -> Definition { +pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition { let font_size = Some(format.font_size.unwrap_or(11.0)); if numbering { Definition { @@ -1007,9 +1106,10 @@ mod tests { .as_ptr(); editor.select([at(1, 1); 2].into()).unwrap(); editor.format(&mut engine, Formatting::Numbering).unwrap(); + // An unnumbered sibling leaves the count alone (`evidence/structural-edits/xml/c8-bs-1.xml`). assert_eq!( numbers(&mut engine, &editor), - [Some(1), None, Some(1), Some(2)] + [Some(1), None, Some(2), Some(3)] ); assert_eq!( editor.active_outline().shaped.paragraphs[0] @@ -1036,7 +1136,7 @@ mod tests { editor.format(&mut engine, Formatting::Outdent).unwrap(); assert_eq!( numbers(&mut engine, &editor), - [Some(1), Some(2), None, Some(1)] + [Some(1), Some(2), None, Some(3)] ); editor.select(all).unwrap(); let state = editor.format_state().unwrap(); @@ -1205,6 +1305,57 @@ mod tests { assert_eq!(editor.active_outline().document, original); } + #[test] + fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() { + use onestore::{RevisionIndex, Store, document::Document}; + let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one"); + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let stored = Page::from_space(&document, space).unwrap().definitions; + let stored = stored + .values() + .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. })) + .collect::>(); + assert_eq!(stored.len(), NoteTag::ALL.len()); + for tag in NoteTag::ALL { + assert!(stored.contains(&&tag.definition()), "{tag:?}"); + } + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["tagged"]); + for tag in NoteTag::ALL { + editor.format(&mut engine, Formatting::Tag(tag)).unwrap(); + } + assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL); + let paragraph = &editor.active_outline().shaped.paragraphs[0]; + use crate::outline::TagIcon; + assert_eq!( + paragraph + .tags + .iter() + .map(|tag| tag.icon) + .collect::>(), + [ + TagIcon::CheckBox { checked: false }, + TagIcon::Star, + TagIcon::Question, + TagIcon::Highlight, + TagIcon::Contact, + TagIcon::Address, + TagIcon::Phone, + ] + ); + // Remember for later and Definition draw no symbol; the newer one's green marks the text. + assert!( + paragraph + .text + .backgrounds() + .all(|(_, color)| color == 0x0000_ff00) + ); + assert!(paragraph.text.backgrounds().next().is_some()); + } + #[test] fn commands_reach_every_cell_a_selection_crosses() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index ff0d93adc401e79bb472dd0e42953ebc38a25e0f..bc45ba4267550f186b35a88b473eced13df66a53 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -47,6 +47,19 @@ pub struct ReadOnlyObject { } impl Content { + /// A picture drawn as a placeholder because it has no data, size or decodable pixels. + pub(crate) fn unavailable( + source: PageObject, + engine: &mut TextEngine, + ) -> Result { + Ok(Self::ReadOnly(ReadOnlyObject::new( + source, + [0.0; 2], + "Image unavailable\nRead-only", + engine, + )?)) + } + pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> { match self { Self::Outline { source, .. } => Some((source.id, &source.layout)), @@ -290,12 +303,7 @@ pub(crate) fn build( || source.layout.max_width.is_none() || source.layout.max_height.is_none() { - objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Image unavailable\nRead-only", - engine, - )?)); + objects.push(Content::unavailable(object, engine)?); continue; } let origin = [ diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index 0b1f5ec886d4316a0fe0b5799a6941558e55113a..e12e71c9de62a8e71238a8174ffcee8b3795454a 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -101,12 +101,23 @@ impl CanvasEditor { Paragraph::new(String::new(), format.clone()), ], )?; + let id = new_id()?; + // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ + // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). + let children = split.replacement.split_off(2); + let head = &mut split.replacement[0]; + let lists = std::mem::take(&mut head.lists); + let mut tags = std::mem::take(&mut head.tags); + tags.append(&mut head.text_mut().unwrap().tags); + let collapsed = std::mem::take(&mut split.replacement[1].collapsed); + let tail = split.replacement[1].id; let cells = split .replacement .drain(..) .map(|mut paragraph| { paragraph.level = 1; paragraph.parent = None; + paragraph.lists.clear(); Ok(TableCell { id: new_id()?, layout: Default::default(), @@ -118,15 +129,15 @@ impl CanvasEditor { }) .collect::, EditError>>()?; let wrapper = PageParagraph { - id: new_id()?, + id, parent: source.parent, level: source.level, style: None, format: source.format.clone(), - lists: Vec::new(), - tags: Vec::new(), + lists, + tags, media: Default::default(), - collapsed: false, + collapsed, content: ParagraphContent::Table(onestore::page::Table { id: new_id()?, columns: vec![ @@ -146,6 +157,14 @@ impl CanvasEditor { }), }; split.replacement.push(wrapper); + split + .replacement + .extend(children.into_iter().map(|mut child| { + if child.parent == Some(tail) { + child.parent = Some(id); + } + child + })); return self.commit( engine, split, @@ -261,7 +280,7 @@ impl CanvasEditor { .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; let Some(cell) = cell.filter(|_| anchor == focus) else { - return self.insert(engine, "\n"); + return self.split(engine); }; let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; let ParagraphContent::Table(table) = &location.node.content else { @@ -284,7 +303,7 @@ impl CanvasEditor { && local + 1 == row.cells[location.column].paragraphs.len() && focus.offset == text.utf16_offset(text.text().len())?; if !exit && !append { - return self.insert(engine, "\n"); + return self.split(engine); } let mut wrapper = location.node.clone(); let ParagraphContent::Table(table) = &mut wrapper.content else { diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 8950762c4d5376b2cb8e35c55ab3a5ca9236e4dc..fa67ea8aae27f9e33de134bd75462a9396c8ff76 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -98,7 +98,12 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg"); const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); +const STAR: &str = include_str!("../../assets/tags/star.svg"); const QUESTION: &str = include_str!("../../assets/tags/question.svg"); +const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg"); +const CONTACT: &str = include_str!("../../assets/tags/contact.svg"); +const ADDRESS: &str = include_str!("../../assets/tags/address.svg"); +const PHONE: &str = include_str!("../../assets/tags/phone.svg"); const MUSIC: &str = include_str!("../../assets/tags/music.svg"); const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); @@ -110,7 +115,12 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { match icon { TagIcon::CheckBox { checked: false } => &[CHECKBOX], TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], + TagIcon::Star => &[STAR], TagIcon::Question => &[QUESTION], + TagIcon::Highlight => &[HIGHLIGHT], + TagIcon::Contact => &[CONTACT], + TagIcon::Address => &[ADDRESS], + TagIcon::Phone => &[PHONE], TagIcon::Music => &[MUSIC], TagIcon::Exclamation => &[EXCLAMATION], TagIcon::RedSquare => &[RED_SQUARE], @@ -140,7 +150,7 @@ mod tests { let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let size = [256, 64]; + let size = [448, 64]; let target = device.create_texture(&wgpu::TextureDescriptor { label: Some("Tag readback test"), size: wgpu::Extent3d { @@ -165,7 +175,12 @@ mod tests { let icons = [ TagIcon::CheckBox { checked: false }, TagIcon::CheckBox { checked: true }, + TagIcon::Star, TagIcon::Question, + TagIcon::Highlight, + TagIcon::Contact, + TagIcon::Address, + TagIcon::Phone, TagIcon::Music, TagIcon::Exclamation, TagIcon::RedSquare, diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index e84242c5532483910e0e4e5d6a848cd8de230eb3..c4a16cb908324020d3f3b2c8bc84d60e9c977236 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -92,10 +92,13 @@ fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec for SceneError { impl PageScene { pub fn new(mut page: Page, engine: &mut TextEngine) -> Result { - let objects = crate::editor::page::build(&mut page, engine, false) + let mut objects = crate::editor::page::build(&mut page, engine, false) .map_err(SceneError::from)? .objects; let pictures = Self::decode_images(&objects, None)?; + pictures.mark_unavailable(&mut objects, engine)?; Ok(Self { reference: Some(objects), ..pictures @@ -154,12 +158,35 @@ impl PageScene { page: Page, engine: &mut TextEngine, ) -> Result<(Self, CanvasEditor), SceneError> { - let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; - Ok((Self::decode_images(&editor.objects, Some(&editor))?, editor)) + let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; + let scene = Self::decode_images(&editor.objects, Some(&editor))?; + scene.mark_unavailable(&mut editor.objects, engine)?; + Ok((scene, editor)) + } + + /// Turns page pictures that did not decode into placeholders; their stored data is kept. + fn mark_unavailable( + &self, + objects: &mut Vec, + engine: &mut TextEngine, + ) -> Result<(), SceneError> { + *objects = std::mem::take(objects) + .into_iter() + .map(|object| match object { + Content::Image(source) + if !self.images.contains_key(&source.id) + && !self.backgrounds.contains_key(&source.id) => + { + Content::unavailable(onestore::page::PageObject::Image(source), engine) + } + object => Ok(object), + }) + .collect::>()?; + Ok(()) } /// Decodes every picture the page draws, page-level ones and those inside outlines, - /// except the template backgrounds it recognises. + /// except the template backgrounds it recognises; one without decodable data is left out. fn decode_images( objects: &[Content], editor: Option<&CanvasEditor>, @@ -215,8 +242,9 @@ impl PageScene { let mut images = std::collections::BTreeMap::new(); let mut bytes = 0_u64; for (id, encoded) in payloads { - let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?) - .map_err(SceneError::Image)?; + let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else { + continue; + }; bytes += image.pixels().len() as u64; if bytes > draw::MAX_IMAGE_BYTES { return Err(SceneError::Image(RenderError::ImageBudget)); @@ -284,6 +312,9 @@ impl PageScene { crate::outline::ObjectKind::Unsupported(_) => { append_placeholder(rect, paper, primitives) } + crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => { + append_placeholder(rect, paper, primitives) + } crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} } if let Some(label) = object.label() { diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 3e0f96458c32d063b2ea7c03d18ac012fdeb2d55..4d492df642143804b356cd17102cbd664a778d18 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -597,7 +597,12 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result use crate::outline::TagIcon; let fallback = match tag.icon { TagIcon::CheckBox { .. } => "To do", + TagIcon::Star => "Important", TagIcon::Question => "Question", + TagIcon::Highlight => "Highlight", + TagIcon::Contact => "Contact", + TagIcon::Address => "Address", + TagIcon::Phone => "Phone number", TagIcon::Music => "Music", TagIcon::Exclamation => "Critical", TagIcon::RedSquare => "Project A", diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 7bf2456d61775d98761a23444ee41e2ea0b15f88..4c21f75684fcee6be94b2c1a1f1e4c448b1d1aff 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -819,7 +819,7 @@ impl PageView { self.commit_text(text) } - /// Text committed by an input method or pasted, line breaks included. + /// Text committed by an input method, line breaks included. pub fn commit_text(&mut self, text: String) -> Result { if !self.accepts_text() { return Ok(Response::default()); @@ -828,6 +828,15 @@ impl PageView { self.edited() } + /// Clipboard text; see [`CanvasEditor::paste`]. + pub fn paste(&mut self, text: &str) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + self.editor.paste(&mut self.engine, text)?; + self.edited() + } + /// An input method's marked text; `cursor` is its UTF-8 selection within `text`. pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result { if !self.accepts_text() { diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index c92155446ba2bf5a49929ed7d0365d3ebed6d1f0..c7353b830f463d24ecd7aa8e736fa6a507e89dfc 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -42,6 +42,8 @@ struct Block { state: (f64, Option), /// A table's vertical coordinates before its offset. rel: Vec, + /// The number the next numbered sibling at the node's level continues from. + count: Option, } /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. @@ -142,6 +144,8 @@ fn verticals<'a>( /// OneNote centers a file's icon and name in a column this wide. const ATTACHMENT_WIDTH: f32 = 54.0; +/// The grey OneNote gives secondary text such as the page date (its `PageDateTime` style). +const PLACEHOLDER: u32 = 0x0080_8080; #[derive(Clone)] pub struct TableLayout { @@ -201,7 +205,12 @@ pub struct ParagraphLayout { #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum TagIcon { CheckBox { checked: bool }, + Star, Question, + Highlight, + Contact, + Address, + Phone, Music, Exclamation, RedSquare, @@ -255,8 +264,16 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { .then_some(size) } +/// The table OneNote writes on every outline it creates, standing in for a missing one. +const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0]; + pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result { - if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { + let indents = if indents.is_empty() { + &DEFAULT_INDENTS + } else { + indents + }; + if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { return Err(LayoutError::InvalidIndentation); } let known = (level as usize).min(indents.len() - 1); @@ -282,8 +299,19 @@ fn follows(number: Option<(u32, bool)>, previous: Option) -> Option { number.map(|(number, restart)| if restart { number } else { next(previous) }) } -/// Removes deeper paragraphs' entries from a flow's `(level, number)` stack of the latest -/// paragraph at each level, and the previous sibling's at `level`, returning that one's number. +/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without +/// a number leave the count alone, as do empty numbered ones after another number +/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`). +fn tally(node: &PageParagraph, number: Option, previous: Option) -> Option { + number + .filter(|_| { + previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty()) + }) + .or(previous) +} + +/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest +/// paragraph at each level, and the previous sibling's at `level`, returning that one's count. fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { siblings.pop(); @@ -296,7 +324,7 @@ fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { /// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4 /// upper and lower letters, as OneNote 2010's `numberSequence`. -fn numeral(sequence: Option, number: u32) -> Result { +pub(crate) fn numeral(sequence: Option, number: u32) -> Result { let roman = |number: u32| { const DIGITS: [(u32, &str); 13] = [ (1000, "M"), @@ -471,8 +499,13 @@ impl ParagraphLayout { else { return Err(LayoutError::InvalidList); }; + let mut color = definition.format.color; let value = match value.split_once('\u{fffd}') { Some((prefix, rest)) => { + // An empty numbered paragraph shows its number as a grey placeholder. + if source.text.text().is_empty() { + color = Some(PLACEHOLDER); + } let current = restart.unwrap_or(next(previous)); number = Some((current, restart.is_some())); let mut rest = rest.chars(); @@ -492,7 +525,7 @@ impl ParagraphLayout { Format { font: font.clone().or_else(|| definition.format.font.clone()), font_size: definition.format.font_size.or(format.font_size), - color: definition.format.color, + color, ..Format::default() }, ); @@ -517,8 +550,13 @@ impl ParagraphLayout { Some(3) => TagIcon::CheckBox { checked: tag.status & 1 != 0, }, + Some(13) => TagIcon::Star, Some(15) => TagIcon::Question, Some(17) => TagIcon::Exclamation, + Some(18) => TagIcon::Phone, + Some(23) => TagIcon::Address, + Some(118) => TagIcon::Contact, + Some(136) => TagIcon::Highlight, Some(100) => TagIcon::RedSquare, Some(101) => TagIcon::YellowSquare, Some(102) => TagIcon::BlueSquare, @@ -702,6 +740,7 @@ impl OutlineLayout { extent: [f32::NEG_INFINITY; 2], state, rel: Vec::new(), + count: None, }; if !hidden { let (space, height, flow, extent) = match &node.content { @@ -836,7 +875,8 @@ impl OutlineLayout { return Err(LayoutError::InvalidSpacing); } } - siblings.push((node.level, number)); + block.count = tally(node, number, previous); + siblings.push((node.level, block.count)); block.state = state; if depth == 0 { result.blocks.push(block); @@ -932,7 +972,7 @@ impl OutlineLayout { for root in (0..range.start).rev() { if nodes[root].level < level { level = nodes[root].level; - siblings.push((level, self.number(nodes, root).map(|(number, _)| number))); + siblings.push((level, self.blocks[root].count)); if level <= 1 { break; } @@ -990,10 +1030,11 @@ impl OutlineLayout { let previous = sibling(&mut siblings, nodes[root].level); let number = self.number(nodes, root); let now = follows(number, previous); - if now != number.map(|(number, _)| number) { + let counted = tally(&nodes[root], now, previous); + if now != number.map(|(number, _)| number) || counted != self.blocks[root].count { renumbered = root + 1; } - siblings.push((nodes[root].level, now)); + siblings.push((nodes[root].level, counted)); if nodes[root].level <= 1 && renumbered <= root { break; } @@ -1678,8 +1719,13 @@ mod tests { }); for (shape, expected) in [ (3, TagIcon::CheckBox { checked: true }), + (13, TagIcon::Star), (15, TagIcon::Question), (17, TagIcon::Exclamation), + (18, TagIcon::Phone), + (23, TagIcon::Address), + (118, TagIcon::Contact), + (136, TagIcon::Highlight), (100, TagIcon::RedSquare), (101, TagIcon::YellowSquare), (102, TagIcon::BlueSquare), diff --git a/crates/canvas/tests/reopen.rs b/crates/canvas/tests/reopen.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc47fa96d07d1fbfee968071640d25894ff38f64 --- /dev/null +++ b/crates/canvas/tests/reopen.rs @@ -0,0 +1,181 @@ +#![cfg(feature = "gpu")] + +use canvas::{gpu::page::PageScene, layout::TextEngine}; +use onestore::{ + ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Page, PageObject, ParagraphContent}, +}; +use std::{fs, path::Path}; + +fn pages(section: &[u8]) -> Vec { + let store = Store::parse(section).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + spaces(&document) + .into_iter() + .map(|space| Page::from_space(&document, space).unwrap()) + .collect() +} + +fn spaces(document: &Document<'_>) -> Vec { + let mut spaces: Vec<_> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect(); + spaces.dedup(); + spaces +} + +/// What `notebook::session::Section::import_page` writes: a new titled page, then the +/// editor's body objects saved onto it. +#[test] +fn an_imported_editor_page_reopens_with_its_title_editable() { + let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap(); + let mut engine = TextEngine::default(); + let (_, editor) = PageScene::from_page(pages(§ion).remove(0), &mut engine).unwrap(); + let copy = editor.page().unwrap().copy().unwrap(); + let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap(); + let created = PreparedEdit::create_page(§ion, &creation).unwrap(); + let mut after = pages(created.as_bytes()).pop().unwrap(); + after + .objects + .retain(|object| matches!(object, PageObject::Title(_))); + after.objects.extend( + copy.objects + .into_iter() + .filter(|object| !matches!(object, PageObject::Title(_))), + ); + after.definitions = copy.definitions; + let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap(); + + let reopened = pages(saved.as_bytes()).pop().unwrap(); + let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), reopened); + let texts = |title: bool| -> Vec { + editor + .outlines() + .iter() + .filter(|outline| outline.title == title) + .map(|outline| { + outline + .document() + .paragraphs() + .next() + .unwrap() + .text() + .to_string() + }) + .collect() + }; + assert_eq!(texts(true), ["Imported 🦋"]); + assert_eq!(texts(false), ["Body 🦀 é"]); +} + +#[test] +fn every_corpus_page_opens() { + fn sections(dir: &Path, found: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + sections(&path, found); + } else if path.extension().is_some_and(|extension| extension == "one") { + found.push(path); + } + } + } + let mut found = Vec::new(); + sections( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"), + &mut found, + ); + let mut engine = TextEngine::default(); + let (mut pages, mut failures) = (0, Vec::new()); + for path in found { + let bytes = fs::read(&path).unwrap(); + // Malformed and encrypted sections are the reader's tests' concern. + let Ok(store) = Store::parse(&bytes) else { + continue; + }; + let Ok(index) = RevisionIndex::parse(&store) else { + continue; + }; + let Ok(document) = Document::parse(&index) else { + continue; + }; + for space in spaces(&document) { + pages += 1; + let result = Page::from_space(&document, space) + .map_err(|error| format!("{error:?}")) + .and_then(|page| { + PageScene::from_page(page, &mut engine).map_err(|error| format!("{error:?}")) + }); + if let Err(error) = result { + failures.push(format!("{} {space}: {error}", path.display())); + } + } + } + assert!(pages > 2000, "{pages}"); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// A page opens whatever its pictures hold, and saving keeps their stored data. +#[test] +fn undecodable_pictures_show_placeholders_and_keep_their_data() { + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"); + let section = |path: &str| pages(&fs::read(corpus.join(path)).unwrap()); + let mut engine = TextEngine::default(); + + // A PNG whose checksum does not match, inside an outline. + let page = section("writer/complex-01/notebook/synthetic.one") + .into_iter() + .find(|page| page.title.starts_with("Fictitious")) + .unwrap(); + let picture = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline + .paragraphs + .iter() + .find_map(|paragraph| match ¶graph.content { + ParagraphContent::Image(image) => Some(image.id), + _ => None, + }) + } + _ => None, + }) + .unwrap(); + let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + assert!(scene.image(picture).is_none()); + assert_eq!(editor.page().unwrap(), page); + + // A TIFF on the page renders; with its data damaged it becomes the placeholder. + let mut page = section("media-edit/candidate/Features.one") + .into_iter() + .find(|page| page.title == "Image tiff") + .unwrap(); + let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + let PageObject::Image(tiff) = page + .objects + .iter_mut() + .find(|object| matches!(object, PageObject::Image(_))) + .unwrap() + else { + unreachable!() + }; + assert!(scene.image(tiff.id).is_some()); + tiff.bytes = Some(b"not a picture".as_slice().into()); + let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + assert_eq!( + scene + .read_only(Some(&editor)) + .map(|object| object.message) + .collect::>(), + ["Image unavailable\nRead-only"] + ); + assert_eq!(editor.page().unwrap(), page); +} diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index dfa4dd091cce7759589636e24938938f2ff4ce83..7e86e37a3e8dcabe7d4db7bdacbc99ef969e1f67 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -13,7 +13,7 @@ render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"] parley.workspace = true bytemuck = { version = "1.25", features = ["derive"], optional = true } -image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } roxmltree = { version = "0.21", optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index b969bbbbabd7cbf22d80dc2a11019cd66601e0a8..30ad19fe24af865fbf3591ee2d6430bf25c0b103 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -624,11 +624,22 @@ impl Page { Ok(width) }) .transpose()?; + let title_text = |node: &Element<'_>| { + node.extra.first().is_some_and(|fields| { + fields.iter().any(|field| { + field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) + }) + }) + }; let mut outline = Outline { id, - title: fields.iter().any(|field| { - field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) - }), + // OneNote flags the outline and its paragraph; `PageCreation` flags only + // the paragraph, and OneNote still shows that outline as the title. + title: title_text(node) + || node + .children + .iter() + .any(|id| revision.nodes.get(id).is_some_and(title_text)), min_width, layout: node.layout.clone(), indents: indents.clone(), diff --git a/crates/snowbound/assets/tags/address.svg b/crates/snowbound/assets/tags/address.svg deleted file mode 100644 index 428250519b22d61e99daa3f5a71634f66d0043f1..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/tags/address.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/snowbound/assets/tags/contact.svg b/crates/snowbound/assets/tags/contact.svg deleted file mode 100644 index 33d2f91d9b16edbf8434235264e8e4c1b158749c..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/tags/contact.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/snowbound/assets/tags/highlight.svg b/crates/snowbound/assets/tags/highlight.svg deleted file mode 100644 index 9922e577f6a695a0c1b4597805fc654a293811c4..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/tags/highlight.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/snowbound/assets/tags/phone.svg b/crates/snowbound/assets/tags/phone.svg deleted file mode 100644 index 07d6349b3f96797e1cba895eff3d0b7ca84b16d0..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/tags/phone.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/crates/snowbound/assets/tags/star.svg b/crates/snowbound/assets/tags/star.svg deleted file mode 100644 index 62f340191717c95b0a5fbc21a2522a3241ed68ad..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/tags/star.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index c2584468a6c03d6b81186e02779023cb5b04e81d..751f1ccac8658b8b33834ac22137e461d131c91a 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -39,10 +39,5 @@ pub const UNDO: &[&str] = art!("icons/undo"); pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); -pub const TAG_STAR: &[&str] = art!("tags/star"); pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); -pub const TAG_HIGHLIGHT: &[&str] = art!("tags/highlight"); -pub const TAG_CONTACT: &[&str] = art!("tags/contact"); -pub const TAG_ADDRESS: &[&str] = art!("tags/address"); -pub const TAG_PHONE: &[&str] = art!("tags/phone"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index f0f6dc1ad786ad90aa3fd24797a46458b422d086..eaf472b951e237188328342d1d7dd25b0a98257f 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1076,27 +1076,27 @@ impl State { }); }); divider(ui, "text", theme); - let tags: [(&[&str], Option); 9] = [ + let tags: [(&[&str], NoteTag); 9] = [ ( tag_sources(TagIcon::CheckBox { checked: false }), - Some(NoteTag::ToDo), + NoteTag::ToDo, ), - (art::TAG_STAR, None), - (tag_sources(TagIcon::Question), Some(NoteTag::Question)), - (art::TAG_REMEMBER, None), - (art::TAG_DEFINITION, None), - (art::TAG_HIGHLIGHT, None), - (art::TAG_CONTACT, None), - (art::TAG_ADDRESS, None), - (art::TAG_PHONE, None), + (tag_sources(TagIcon::Star), NoteTag::Important), + (tag_sources(TagIcon::Question), NoteTag::Question), + (art::TAG_REMEMBER, NoteTag::RememberForLater), + (art::TAG_DEFINITION, NoteTag::Definition), + (tag_sources(TagIcon::Highlight), NoteTag::Highlight), + (tag_sources(TagIcon::Contact), NoteTag::Contact), + (tag_sources(TagIcon::Address), NoteTag::Address), + (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber), ]; group(ui, "tags", |ui| { for (index, tags) in tags.chunks(5).enumerate() { row(ui, index, |ui| { for (column, (icon, tag)) in tags.iter().enumerate() { - let lit = tag.is_some_and(|tag| state.tags.contains(&tag)); + let lit = state.tags.contains(tag); if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked { - command = tag.map(Formatting::Tag); + command = Some(Formatting::Tag(*tag)); } } if index == 1 { @@ -1448,7 +1448,7 @@ impl State { Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, Command::Page(Request::Paste) => { let text = self.clipboard.get_text()?; - let response = self.view.commit_text(text)?; + let response = self.view.paste(&text)?; self.respond(response); } Command::Page(Request::CharacterPalette) => macos::show_character_palette(),