From 332e865dc1d25461af433a52794ec50ebf81f388 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 20:25:55 -0700 Subject: [PATCH] feat: formatting commands for the toolbar The editor applies OneNote's Basic Text commands: bold, italic, underline, strikethrough, sub- and superscript, font, size, colour, highlight and clearing to the selection or the caret's next text; bullets, numbering, indent, outdent and alignment to the paragraphs it touches; To Do and Question tags and check boxes. Each is one undo step, and format_state reports what the toolbar should show as pressed. Numbered lists render, so outlines holding them open editable. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- crates/canvas/src/editor.rs | 71 +- crates/canvas/src/editor/format.rs | 1336 ++++++++++++++++++++++++ crates/canvas/src/interaction/mod.rs | 12 +- crates/canvas/src/interaction/tests.rs | 31 + crates/canvas/src/outline.rs | 267 ++++- 5 files changed, 1663 insertions(+), 54 deletions(-) create mode 100644 crates/canvas/src/editor/format.rs diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 0d58f77ee5ef2f6caa539b0e637807d008b72363..44cda7d0629a12db8d922d723fab1f205b8167a2 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -20,8 +20,10 @@ use std::{ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; +mod format; pub(crate) mod page; mod table; +pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle}; pub use page::ReadOnlyObject; #[derive(Debug)] @@ -83,6 +85,8 @@ pub struct CanvasEditor { redo: Vec, composition: Option, preferred_x: Option, + /// Formatting chosen at a caret for the text typed there next, until an edit. + pending: Option<(ExGuid, TextPosition, onestore::document::Format)>, } /// Imported page state the editable content does not carry. @@ -164,8 +168,15 @@ impl TextOutline { fixed_width, 0, None, - &mut |paragraph, width, indents| { - ParagraphLayout::shape(engine, paragraph, width, indents, &BTreeMap::new()) + &mut |paragraph, previous, width, indents| { + ParagraphLayout::shape( + engine, + paragraph, + previous, + width, + indents, + &BTreeMap::new(), + ) }, )?; Ok(Self { @@ -645,6 +656,7 @@ impl CanvasEditor { redo: Vec::new(), composition: None, preferred_x: None, + pending: None, }) } @@ -982,6 +994,7 @@ impl CanvasEditor { redo: Vec::new(), composition: None, preferred_x: None, + pending: None, }) } @@ -1069,8 +1082,9 @@ impl CanvasEditor { Ok(()) } - fn blank_paragraph(&self, base: &PageParagraph) -> Result { - let format = match base.style { + /// The format a paragraph style gives text, which clearing formatting returns to. + fn style_format(&self, style: Option) -> Result { + Ok(match style { Some(id) => self .definitions .get(&id) @@ -1081,7 +1095,11 @@ impl CanvasEditor { .format .clone(), None => Default::default(), - }; + }) + } + + fn blank_paragraph(&self, base: &PageParagraph) -> Result { + let format = self.style_format(base.style)?; let mut node = crate::document::node(Paragraph::new(String::new(), format.clone()), format)?; node.level = base.level; @@ -1529,8 +1547,15 @@ impl CanvasEditor { true, 0, None, - &mut |paragraph, width, indents| { - ParagraphLayout::shape(engine, paragraph, width, indents, &self.definitions) + &mut |paragraph, previous, width, indents| { + ParagraphLayout::shape( + engine, + paragraph, + previous, + width, + indents, + &self.definitions, + ) }, )?; let mut layout = outline.layout.clone(); @@ -2190,12 +2215,7 @@ impl CanvasEditor { pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { let start = self.active_outline().selection.positions[0] .min(self.active_outline().selection.positions[1]); - let format = self - .active_outline() - .document - .paragraph(start.paragraph) - .unwrap() - .format_at(start.offset)?; + let format = self.typing_format(start)?; let replacement = text .split('\n') .map(|line| Paragraph::new(line.to_owned(), format.clone())) @@ -2225,9 +2245,13 @@ impl CanvasEditor { let range = local_start ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph); let nodes = outline.document.container(container)?; - crate::document::validate_flat(nodes.iter().enumerate().filter_map(|(index, node)| { - (range.contains(&index) || node.text().is_some()).then_some(node) - }))?; + // 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) { return Ok(false); @@ -2409,6 +2433,7 @@ impl CanvasEditor { engine: &mut TextEngine, history: History, ) -> Result { + self.pending = None; let valid = match &history { History::Date(date) => self .date @@ -2718,12 +2743,7 @@ impl CanvasEditor { .try_into() .map_err(|_| EditError::TextTooLong)?, )?; - let format = self - .active_outline() - .document - .paragraph(range.start.paragraph) - .unwrap() - .format_at(range.start.offset)?; + let format = self.typing_format(range.start)?; let replacement = text .split('\n') .map(|part| Paragraph::new(part.to_owned(), format.clone())) @@ -2821,6 +2841,7 @@ impl CanvasEditor { } fn record_change(&mut self, change: TextChange) { + self.pending = None; if let Focus::Draft { index, .. } = self.active { let Focus::Draft { mut outline, .. } = std::mem::replace(&mut self.active, Focus::Outline(index)) @@ -2878,7 +2899,7 @@ impl CanvasEditor { &outline.indents, outline.wrap_width(), outline.layout.width_set_by_user == Some(true), - &mut |_, _, _| Err(LayoutError::UnsupportedContent), + &mut |_, _, _, _| Err(LayoutError::UnsupportedContent), ) .expect("removing trailing text lays nothing out anew"); outline @@ -2983,8 +3004,8 @@ impl CanvasEditor { &outline.indents, outline.wrap_width(), outline.layout.width_set_by_user == Some(true), - &mut |node, width, indents| { - ParagraphLayout::shape(engine, node, width, indents, &self.definitions) + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions) }, )?; let [replaced, ..] = outline.shaped.pieces(relayout.range.clone()); diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs new file mode 100644 index 0000000000000000000000000000000000000000..7fa16adbe80b63b1eeec2aba3727f9470645698b --- /dev/null +++ b/crates/canvas/src/editor/format.rs @@ -0,0 +1,1336 @@ +//! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags. + +use super::*; +use onestore::document::{Format, Kind, Tag}; +use onestore::page::ParagraphContent; +use onestore::page::text::new_id; + +/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour. +const AUTOMATIC: u32 = 0xff00_0000; +/// The one checkable note tag shape the page draws. +const CHECKBOX: u16 = 3; + +/// A toolbar command on the selection, or with only a caret, on the text typed there next. +#[derive(Clone, Debug, PartialEq)] +pub enum Formatting { + /// Removed where every selected character has it, applied to all of them otherwise. + Toggle(Toggle), + Font(String), + FontSize(f32), + /// A COLORREF, or `None` for automatic. + Color(Option), + /// A COLORREF, or `None` for no highlight. + Highlight(Option), + /// Returns text to its paragraph style. + Clear, + Bullets, + Numbering, + Indent, + Outdent, + Align(Alignment), + Tag(NoteTag), + /// Checks the selected paragraphs' check boxes, or clears them when all are checked. + Check, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Toggle { + Bold, + Italic, + Underline, + Strikethrough, + /// Excludes superscript. + Subscript, + /// Excludes subscript. + Superscript, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Alignment { + Left, + Center, + 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`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoteTag { + ToDo, + Question, +} + +/// What the selection shows on the toolbar. +#[derive(Clone, Debug, PartialEq)] +pub struct FormatState { + /// Attributes every selected character has. + pub toggles: Vec, + /// The font and size every selected character shares. + pub font: Option, + pub font_size: Option, + /// The alignment every selected paragraph shares. + pub alignment: Option, + /// Whether every selected paragraph is bulleted or numbered. + pub bullets: bool, + pub numbering: bool, + /// Tags every selected paragraph has. + pub tags: Vec, +} + +impl Toggle { + const ALL: [Self; 6] = [ + Self::Bold, + Self::Italic, + Self::Underline, + Self::Strikethrough, + Self::Subscript, + Self::Superscript, + ]; + + fn get(self, format: &Format) -> Option { + match self { + Self::Bold => format.bold, + Self::Italic => format.italic, + Self::Underline => format.underline, + Self::Strikethrough => format.strike, + Self::Subscript => format.subscript, + Self::Superscript => format.superscript, + } + } + + fn slot(self, format: &mut Format) -> &mut Option { + match self { + Self::Bold => &mut format.bold, + Self::Italic => &mut format.italic, + Self::Underline => &mut format.underline, + Self::Strikethrough => &mut format.strike, + Self::Subscript => &mut format.subscript, + Self::Superscript => &mut format.superscript, + } + } +} + +impl NoteTag { + const ALL: [Self; 2] = [Self::ToDo, Self::Question]; + + fn definition(self) -> Definition { + let (label, action_type, shape) = match self { + Self::ToDo => ("To Do", 0, CHECKBOX), + Self::Question => ("Question", 2, 15), + }; + Definition { + kind: Kind::TagDefinition { + label: Some(label.into()), + action_type: Some(action_type), + shape: Some(shape), + color: None, + highlight: None, + }, + format: Format::default(), + } + } +} + +/// Where a selection starts and ends, as text leaf identity and byte offset. +type Ends = [(ExGuid, usize); 2]; + +/// The selection's ends and the container range holding both; a selection ending at a +/// paragraph's start ends with the paragraph before. +fn selected( + document: &TextDocument, + selection: Selection, +) -> Result<(Option, Range, Ends), EditError> { + let [anchor, focus] = selection.positions; + let (start, mut end) = (anchor.min(focus), anchor.max(focus)); + if end.offset == 0 && end.paragraph > start.paragraph { + end.paragraph -= 1; + let text = document + .paragraph(end.paragraph) + .ok_or(EditError::InvalidRange)?; + end.offset = text.utf16_offset(text.text().len())?; + } + let (container, first, head) = document + .leaf(start.paragraph) + .ok_or(EditError::InvalidRange)?; + let (end_container, last, tail) = document + .leaf(end.paragraph) + .ok_or(EditError::InvalidRange)?; + let range = if container == end_container { + first..last + 1 + } else { + let root = |container: Option, index| { + container.map_or(Ok(index), |cell| document.root(cell)) + }; + root(container, first)?..root(end_container, last)? + 1 + }; + let byte = |node: &PageParagraph, offset| node.text().unwrap().text.byte_offset(offset); + Ok(( + if container == end_container { + container + } else { + None + }, + range, + [ + (head.id, byte(head, start.offset)?), + (tail.id, byte(tail, end.offset)?), + ], + )) +} + +/// The text leaves of `nodes` from `start` through `end`, each with the byte range selected. +fn covered( + nodes: &[PageParagraph], + [start, end]: Ends, +) -> impl Iterator)> { + let mut done = false; + leaves(nodes, None) + .map(|(_, _, node)| node) + .skip_while(move |node| node.id != start.0) + .take_while(move |node| !std::mem::replace(&mut done, node.id == end.0)) + .map(move |node| { + let from = if node.id == start.0 { start.1 } else { 0 }; + let to = if node.id == end.0 { + end.1 + } else { + node.text().unwrap().text.text().len() + }; + (node, from..to) + }) +} + +fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) { + for node in nodes { + match &mut node.content { + ParagraphContent::Text(_) => change(node), + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + leaves_mut(&mut cell.paragraphs, change); + } + } + _ => {} + } + } +} + +/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty. +fn restyle(text: &Paragraph, range: Range, change: impl Fn(&mut Format)) -> Paragraph { + if text.text().is_empty() { + let mut format = text.spans()[0].format.clone(); + change(&mut format); + return Paragraph::new(String::new(), format); + } + let mut runs = Vec::new(); + let mut start = 0; + for span in text.spans() { + let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end)); + for (part, inside) in [ + (start..from, false), + (from..to, true), + (to..span.end, false), + ] { + if !part.is_empty() { + let mut format = span.format.clone(); + if inside { + change(&mut format); + } + runs.push((text.text()[part].to_owned(), format)); + } + } + start = span.end; + } + Paragraph::from_runs(runs) +} + +/// The formats of the visible characters in `range`, or of the paragraphs without text when the +/// selection holds no characters. +fn character_formats<'a>( + covered: impl Iterator)>, +) -> Vec<&'a Format> { + let mut characters = Vec::new(); + let mut blank = Vec::new(); + for (node, range) in covered { + let text = &node.text().unwrap().text; + if text.text().is_empty() { + blank.push(&text.spans()[0].format); + } + let mut start = 0; + for span in text.spans() { + if start < range.end && span.end > range.start && span.format.hidden != Some(true) { + characters.push(&span.format); + } + start = span.end; + } + } + if characters.is_empty() { + blank + } else { + characters + } +} + +fn common(mut values: impl Iterator>) -> Option { + let first = values.next()??; + values + .all(|value| value.as_ref() == Some(&first)) + .then_some(first) +} + +/// Seconds since 1980, as note tags date themselves. +fn time32() -> Option { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + u32::try_from(now.checked_sub(315_532_800)?).ok() +} + +fn tags(node: &PageParagraph) -> impl Iterator { + node.tags.iter().chain(&node.text().unwrap().tags) +} + +impl CanvasEditor { + /// The format text typed at `position` takes: the caret's pending format, else the text's + /// before it. + pub(super) fn typing_format(&self, position: TextPosition) -> Result { + if let Some((id, at, format)) = &self.pending + && *id == self.active_outline().id + && *at == position + { + return Ok(format.clone()); + } + Ok(self + .active_outline() + .document + .paragraph(position.paragraph) + .ok_or(EditError::InvalidRange)? + .format_at(position.offset)? + .clone()) + } + + fn list(&self, node: &PageParagraph) -> Option { + match &self.definitions.get(node.lists.last()?)?.kind { + Kind::List { format, .. } => Some( + if format + .as_ref() + .is_some_and(|format| format.contains('\u{fffd}')) + { + Formatting::Numbering + } else { + Formatting::Bullets + }, + ), + _ => None, + } + } + + fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> { + Some(&self.definitions.get(tag.definition.as_ref()?)?.kind) + } + + pub fn format_state(&self) -> Result { + let outline = self.active_outline(); + let (container, range, ends) = selected(&outline.document, outline.selection)?; + let nodes = &outline.document.container(container)?[range]; + let paragraphs = covered(nodes, ends) + .map(|(node, _)| node) + .collect::>(); + let caret; + let formats = if ends[0] == ends[1] { + let [anchor, focus] = outline.selection.positions; + caret = self.typing_format(anchor.min(focus))?; + vec![&caret] + } else { + character_formats(covered(nodes, ends)) + }; + let list = |kind| { + paragraphs + .iter() + .all(|node| self.list(node).as_ref() == Some(&kind)) + }; + Ok(FormatState { + toggles: Toggle::ALL + .into_iter() + .filter(|toggle| { + formats + .iter() + .all(|format| toggle.get(format) == Some(true)) + }) + .collect(), + font: common(formats.iter().map(|format| format.font.clone())), + font_size: common(formats.iter().map(|format| format.font_size)), + alignment: common(paragraphs.iter().map(|node| { + Some( + match node.text().unwrap().text.spans()[0].format.alignment { + Some(1) => Alignment::Center, + Some(2) => Alignment::Right, + _ => Alignment::Left, + }, + ) + })), + bullets: list(Formatting::Bullets), + numbering: list(Formatting::Numbering), + tags: NoteTag::ALL + .into_iter() + .filter(|tag| { + let kind = tag.definition().kind; + paragraphs + .iter() + .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind))) + }) + .collect(), + }) + } + + /// Applies a toolbar command as one undo step that keeps the selection. + pub fn format( + &mut self, + engine: &mut TextEngine, + command: Formatting, + ) -> 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 mut replacement = outline.document.container(container)?[range.clone()].to_vec(); + let mut ranges = covered(&replacement, ends) + .map(|(node, range)| (node.id, range)) + .collect::>(); + let bases = |nodes: &[PageParagraph]| { + covered(nodes, ends) + .map(|(node, _)| Ok((node.id, self.style_format(node.style)?))) + .collect::, EditError>>() + }; + match &command { + Formatting::Align(_) + | Formatting::Bullets + | Formatting::Numbering + | Formatting::Tag(_) + | Formatting::Check + if title => + { + return Ok(()); + } + Formatting::Indent | Formatting::Outdent => { + self.indent(engine, command == Formatting::Outdent)?; + return Ok(()); + } + Formatting::Align(alignment) => { + let bases = bases(&replacement)?; + leaves_mut(&mut replacement, &mut |node| { + if ranges.remove(&node.id).is_some() { + let value = match alignment { + Alignment::Left => bases[&node.id].alignment.map(|_| 0), + Alignment::Center => Some(1), + Alignment::Right => Some(2), + }; + let text = &mut node.text_mut().unwrap().text; + *text = restyle(text, 0..text.text().len(), |format| { + format.alignment = value; + }); + } + }); + } + Formatting::Bullets | Formatting::Numbering => { + let remove = covered(&replacement, ends) + .all(|(node, _)| self.list(node).as_ref() == Some(&command)); + let wanted = covered(&replacement, ends) + .map(|(node, _)| node) + .filter(|node| !remove && self.list(node).as_ref() != Some(&command)) + .map(|node| { + let format = &node.text().unwrap().text.spans()[0].format; + ( + node.id, + list_definition(command == Formatting::Numbering, format), + ) + }) + .collect::>(); + // A list definition belongs to one paragraph, as OneNote stores it. + let mut lists = BTreeMap::new(); + for (node, definition) in wanted { + let id = new_id()?; + self.definitions.insert(id, definition); + lists.insert(node, id); + } + leaves_mut(&mut replacement, &mut |node| { + if ranges.remove(&node.id).is_some() { + if remove { + node.lists.clear(); + } else if let Some(id) = lists.get(&node.id) { + node.lists = vec![*id]; + } + } + }); + } + Formatting::Tag(tag) => { + let definition = tag.definition(); + let Kind::TagDefinition { + action_type, shape, .. + } = definition.kind + else { + unreachable!() + }; + let existing = self + .definitions + .iter() + .find(|(_, other)| other.kind == definition.kind); + let id = match existing { + Some((id, _)) => *id, + None => { + let id = new_id()?; + self.definitions.insert(id, definition.clone()); + id + } + }; + let has = |node: &PageParagraph| { + tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) + }; + let remove = covered(&replacement, ends).all(|(node, _)| has(node)); + let created = time32(); + leaves_mut(&mut replacement, &mut |node| { + if ranges.remove(&node.id).is_none() || !remove && has(node) { + return; + } + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + // An element holds one tag of each action type. + for tags in [&mut node.tags, &mut text.tags] { + tags.retain(|tag| match self.tag_kind(tag) { + Some(kind) if remove => *kind != definition.kind, + Some(Kind::TagDefinition { + action_type: other, .. + }) => *other != action_type, + _ => true, + }); + } + if !remove { + let checkable = shape == Some(CHECKBOX); + // Stored newest first. + text.tags.insert( + 0, + Tag { + definition: Some(id), + action_type: None, + status: u16::from(!checkable), + created, + completed: if checkable { Some(0) } else { created }, + start: None, + due: None, + task_id: None, + extra_set: 0, + }, + ); + } + }); + } + Formatting::Check => { + let checkable = |tag: &Tag| { + matches!( + self.tag_kind(tag), + Some(Kind::TagDefinition { + shape: Some(CHECKBOX), + .. + }) + ) + }; + let checked = covered(&replacement, ends) + .flat_map(|(node, _)| tags(node)) + .filter(|tag| checkable(tag)) + .all(|tag| tag.status & 1 != 0); + let completed = if checked { Some(0) } else { time32() }; + leaves_mut(&mut replacement, &mut |node| { + if ranges.remove(&node.id).is_none() { + return; + } + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + for tag in node.tags.iter_mut().chain(&mut text.tags) { + if checkable(tag) && (tag.status & 1 != 0) == checked { + tag.status ^= 1; + tag.completed = completed; + } + } + }); + } + Formatting::Toggle(_) + | Formatting::Font(_) + | Formatting::FontSize(_) + | Formatting::Color(_) + | Formatting::Highlight(_) + | Formatting::Clear => { + let on = match command { + Formatting::Toggle(toggle) => !character_formats(covered(&replacement, ends)) + .iter() + .all(|format| toggle.get(format) == Some(true)), + _ => true, + }; + let change = |base: &Format, format: &mut Format| match &command { + Formatting::Toggle(toggle) => { + let exclusive = match toggle { + Toggle::Subscript => Some(Toggle::Superscript), + Toggle::Superscript => Some(Toggle::Subscript), + _ => None, + }; + for toggle in exclusive.into_iter().chain([*toggle]) { + *toggle.slot(format) = toggle.get(base).map(|_| false); + } + if on { + *toggle.slot(format) = Some(true); + } + } + Formatting::Font(font) => format.font = Some(font.clone()), + Formatting::FontSize(size) => format.font_size = Some(*size), + Formatting::Color(color) => { + format.color = color.or(base.color.map(|_| AUTOMATIC)); + } + Formatting::Highlight(color) => { + format.highlight = color.or(base.highlight.map(|_| AUTOMATIC)); + } + _ => { + for toggle in Toggle::ALL { + *toggle.slot(format) = toggle.get(base); + } + format.font.clone_from(&base.font); + format.font_size = base.font_size; + format.color = base.color; + format.highlight = base.highlight; + } + }; + let bases = bases(&replacement)?; + if ends[0] == ends[1] { + let [anchor, focus] = selection.positions; + let caret = anchor.min(focus); + let mut format = self.typing_format(caret)?; + change(&bases[&ends[0].0], &mut format); + self.pending = Some((id, caret, format)); + return Ok(()); + } + leaves_mut(&mut replacement, &mut |node| { + if let Some(range) = ranges.remove(&node.id) { + let base = &bases[&node.id]; + let text = &mut node.text_mut().unwrap().text; + *text = restyle(text, range, |format| change(base, format)); + } + }); + } + } + if self.active_outline().document.container(container)?[range.clone()] == replacement[..] { + return Ok(()); + } + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range, + replacement, + }, + selection, + ) + } +} + +/// 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 { + let font_size = Some(format.font_size.unwrap_or(11.0)); + if numbering { + Definition { + kind: Kind::List { + font: None, + format: Some("\u{fffd}\u{0}.".into()), + restart: None, + bullet: None, + }, + format: Format { + bold: Some(false), + italic: Some(false), + font: format.font.clone(), + font_size, + color: Some(AUTOMATIC), + language: format.language, + ..Format::default() + }, + } + } else { + Definition { + kind: Kind::List { + font: Some("Calibri".into()), + format: Some("\u{2022}".into()), + restart: None, + bullet: Some(1), + }, + format: Format { + font_size, + color: Some(AUTOMATIC), + ..Format::default() + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(paragraph: usize, offset: u32) -> TextPosition { + TextPosition { paragraph, offset } + } + + fn calibri() -> Format { + Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Format::default() + } + } + + fn editor(engine: &mut TextEngine, paragraphs: Vec) -> CanvasEditor { + CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 300.0).unwrap() + } + + fn plain(engine: &mut TextEngine, texts: &[&str]) -> CanvasEditor { + let paragraphs = texts + .iter() + .map(|text| Paragraph::new((*text).into(), calibri())) + .collect(); + editor(engine, paragraphs) + } + + /// Each paragraph's runs as text and the value `field` reads from their format. + fn runs(editor: &CanvasEditor, field: impl Fn(&Format) -> T) -> Vec> { + editor + .active_outline() + .document + .paragraphs() + .map(|paragraph| { + let mut start = 0; + paragraph + .spans() + .iter() + .map(|span| { + let text = paragraph.text()[start..span.end].to_owned(); + start = span.end; + (text, field(&span.format)) + }) + .collect() + }) + .collect() + } + + fn run(text: &str, value: Option) -> (String, Option) { + (text.into(), value) + } + + #[test] + fn toggles_apply_unless_every_character_has_them_as_one_undo_step() { + let mut engine = TextEngine::default(); + let mut editor = editor( + &mut engine, + vec![ + Paragraph::from_runs([ + ("plain ".into(), calibri()), + ( + "bold".into(), + Format { + bold: Some(true), + ..calibri() + }, + ), + ]), + Paragraph::new("next".into(), calibri()), + ], + ); + let original = editor.active_outline().document.clone(); + let selection = Selection::from([at(1, 2), at(0, 3)]); + editor.select(selection).unwrap(); + let bold = |editor: &CanvasEditor| runs(editor, |format| format.bold); + editor + .format(&mut engine, Formatting::Toggle(Toggle::Bold)) + .unwrap(); + assert_eq!( + bold(&editor), + [ + vec![run("pla", None), run("in bold", Some(true))], + vec![run("ne", Some(true)), run("xt", None)] + ] + ); + assert_eq!(editor.selection(), selection); + assert!( + editor + .format_state() + .unwrap() + .toggles + .contains(&Toggle::Bold) + ); + editor + .format(&mut engine, Formatting::Toggle(Toggle::Bold)) + .unwrap(); + assert_eq!( + bold(&editor), + [vec![run("plain bold", None)], vec![run("next", None)]] + ); + assert!(editor.undo(&mut engine).unwrap()); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, original); + assert_eq!(editor.selection(), selection); + assert!(!editor.undo(&mut engine).unwrap()); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]); + } + + #[test] + fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["ab"]); + editor.select([at(0, 1); 2].into()).unwrap(); + for toggle in [Toggle::Bold, Toggle::Superscript, Toggle::Subscript] { + editor + .format(&mut engine, Formatting::Toggle(toggle)) + .unwrap(); + } + assert!(!editor.undo(&mut engine).unwrap()); + assert_eq!( + editor.format_state().unwrap().toggles, + [Toggle::Bold, Toggle::Subscript] + ); + editor.insert(&mut engine, "x").unwrap(); + let typed = Format { + bold: Some(true), + subscript: Some(true), + ..calibri() + }; + assert_eq!( + runs(&editor, Clone::clone), + [vec![ + ("a".into(), calibri()), + ("x".into(), typed.clone()), + ("b".into(), calibri()) + ]] + ); + editor + .format(&mut engine, Formatting::Toggle(Toggle::Italic)) + .unwrap(); + editor.select([at(0, 0); 2].into()).unwrap(); + editor.insert(&mut engine, "y").unwrap(); + assert_eq!(runs(&editor, |format| format.italic)[0][0], run("ya", None)); + + editor + .format(&mut engine, Formatting::FontSize(20.0)) + .unwrap(); + editor.compose(&mut engine, "k".into(), 1..1).unwrap(); + editor.compose(&mut engine, "kl".into(), 2..2).unwrap(); + editor.commit_text(&mut engine, "kl".into()).unwrap(); + assert_eq!( + runs(&editor, |format| format.font_size)[0][..2], + [("y".into(), Some(11.0)), ("kl".into(), Some(20.0))] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + "yaxb" + ); + } + + #[test] + fn clearing_and_removing_attributes_return_to_the_paragraph_style() { + let mut engine = TextEngine::default(); + let style = new_id().unwrap(); + let heading = Format { + bold: Some(true), + color: Some(0x0012_3456), + font: Some("Georgia".into()), + font_size: Some(18.0), + ..Format::default() + }; + let mut nodes = TextDocument::new(vec![Paragraph::new("Heading".into(), heading.clone())]) + .unwrap() + .nodes() + .to_vec(); + nodes[0].style = Some(style); + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + max_width: Some(300.0), + ..Default::default() + }, + indents: vec![18.0, 0.0, 27.0, 27.0], + paragraphs: nodes, + unsupported: Vec::new(), + }; + let definitions = BTreeMap::from([( + style, + Definition { + kind: Kind::Style { + name: Some("h1".into()), + }, + format: heading.clone(), + }, + )]); + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], definitions).unwrap(); + editor.select_all().unwrap(); + let state = editor.format_state().unwrap(); + assert_eq!(state.toggles, [Toggle::Bold]); + assert_eq!( + (state.font.as_deref(), state.font_size, state.alignment), + (Some("Georgia"), Some(18.0), Some(Alignment::Left)) + ); + for command in [ + Formatting::Toggle(Toggle::Bold), + Formatting::Color(None), + Formatting::Highlight(Some(0x0000_ffff)), + Formatting::Font("Arial".into()), + Formatting::Align(Alignment::Center), + ] { + editor.format(&mut engine, command).unwrap(); + } + editor.select([at(0, 0), at(0, 4)].into()).unwrap(); + editor + .format(&mut engine, Formatting::FontSize(9.0)) + .unwrap(); + editor.select_all().unwrap(); + assert_eq!(editor.format_state().unwrap().font_size, None); + let format = editor + .active_outline() + .document + .paragraph(0) + .unwrap() + .spans()[1] + .format + .clone(); + assert_eq!( + format, + Format { + bold: Some(false), + color: Some(AUTOMATIC), + highlight: Some(0x0000_ffff), + font: Some("Arial".into()), + alignment: Some(1), + ..heading.clone() + } + ); + editor.format(&mut engine, Formatting::Clear).unwrap(); + assert_eq!( + runs(&editor, Clone::clone), + [vec![( + "Heading".into(), + Format { + alignment: Some(1), + ..heading + } + )]] + ); + editor + .format(&mut engine, Formatting::Highlight(None)) + .unwrap(); + for _ in 0..7 { + assert!(editor.undo(&mut engine).unwrap()); + } + assert!(!editor.undo(&mut engine).unwrap()); + } + + /// Each paragraph's number as laid out, checked against laying the outline out afresh. + fn numbers(engine: &mut TextEngine, editor: &CanvasEditor) -> Vec> { + 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 laid = |layout: &OutlineLayout| { + layout + .paragraphs + .iter() + .map(|paragraph| { + let markers = paragraph.markers.iter().map(|(marker, origin)| { + (*origin, marker.lines().next().unwrap().0.metrics().advance) + }); + (paragraph.number, markers.collect::>()) + }) + .collect::>() + }; + assert_eq!(laid(&outline.shaped), laid(&fresh)); + outline + .shaped + .paragraphs + .iter() + .map(|paragraph| paragraph.number.map(|(number, _)| number)) + .collect() + } + + #[test] + fn lists_toggle_switch_kind_and_renumber_only_what_changes() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["one", "two", "three", "four"]); + let original = editor.active_outline().document.clone(); + let all = Selection::from([at(0, 0), at(3, 4)]); + editor.select(all).unwrap(); + editor.format(&mut engine, Formatting::Bullets).unwrap(); + let state = editor.format_state().unwrap(); + assert!(state.bullets && !state.numbering); + assert_eq!(numbers(&mut engine, &editor), [None; 4]); + let bullet = editor.active_outline().document.nodes()[0].lists[0]; + assert_eq!( + editor.definitions[&bullet], + list_definition(false, &calibri()) + ); + editor.format(&mut engine, Formatting::Numbering).unwrap(); + let state = editor.format_state().unwrap(); + assert!(!state.bullets && state.numbering); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), Some(2), Some(3), Some(4)] + ); + let untouched = editor.active_outline().shaped.paragraphs[0] + .text + .shaped + .styles() + .as_ptr(); + editor.select([at(1, 1); 2].into()).unwrap(); + editor.format(&mut engine, Formatting::Numbering).unwrap(); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), None, Some(1), Some(2)] + ); + assert_eq!( + editor.active_outline().shaped.paragraphs[0] + .text + .shaped + .styles() + .as_ptr(), + untouched + ); + editor.undo(&mut engine).unwrap(); + editor.select([at(2, 0); 2].into()).unwrap(); + editor.format(&mut engine, Formatting::Indent).unwrap(); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), Some(2), Some(1), Some(3)] + ); + assert_eq!(editor.selection(), Selection::from([at(2, 0); 2])); + editor.format(&mut engine, Formatting::Bullets).unwrap(); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), Some(2), None, Some(3)] + ); + assert!(editor.format_state().unwrap().bullets); + editor.format(&mut engine, Formatting::Outdent).unwrap(); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), Some(2), None, Some(1)] + ); + editor.select(all).unwrap(); + let state = editor.format_state().unwrap(); + assert!(!state.bullets && !state.numbering); + for _ in 0..6 { + editor.undo(&mut engine).unwrap(); + } + assert_eq!(editor.active_outline().document, original); + assert_eq!(numbers(&mut engine, &editor), [None; 4]); + } + + #[test] + fn numbering_follows_stored_sequences_restarts_and_nesting() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["a", "b", "c", "d", "e"]); + let mut nodes = editor.active_outline().document.nodes().to_vec(); + for (node, (sequence, restart, level)) in nodes.iter_mut().zip([ + ('\u{1}', None, 1), + ('\u{1}', None, 1), + ('\u{4}', None, 2), + ('\u{1}', Some(9), 1), + ('\u{0}', None, 1), + ]) { + let id = new_id().unwrap(); + let mut definition = list_definition(true, &calibri()); + let Kind::List { + format, + restart: value, + .. + } = &mut definition.kind + else { + unreachable!() + }; + *format = Some(format!("(\u{fffd}{sequence})")); + *value = restart; + editor.definitions.insert(id, definition); + node.lists = vec![id]; + node.level = level; + } + let outline = Outline { + paragraphs: nodes, + ..editor.active_outline().snapshot() + }; + let editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions).unwrap(); + assert_eq!( + numbers(&mut engine, &editor), + [Some(1), Some(2), Some(1), Some(9), Some(10)] + ); + } + + fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> { + let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap(); + tags(node) + .map(|tag| { + let Some(Kind::TagDefinition { label, .. }) = editor.tag_kind(tag) else { + panic!("{tag:?}") + }; + let settled = tag.completed + == if tag.status & 1 == 0 { + Some(0) + } else { + tag.created + }; + ( + label.clone().unwrap(), + tag.status, + settled || tag.status & 1 == 1, + ) + }) + .collect() + } + + #[test] + fn tags_toggle_check_and_replace_tags_of_their_action_type() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["task", "other", "music"]); + let music = new_id().unwrap(); + editor.definitions.insert( + music, + Definition { + kind: Kind::TagDefinition { + label: Some("Music".into()), + action_type: Some(2), + shape: Some(121), + color: None, + highlight: None, + }, + format: Format::default(), + }, + ); + let mut nodes = editor.active_outline().document.nodes().to_vec(); + nodes[2].tags.push(Tag { + definition: Some(music), + action_type: None, + status: 1, + created: Some(1), + completed: Some(1), + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + let outline = Outline { + paragraphs: nodes, + ..editor.active_outline().snapshot() + }; + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions).unwrap(); + let original = editor.active_outline().document.clone(); + editor.select([at(0, 1), at(1, 2)].into()).unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + assert_eq!(text_tags(&editor, 0), [("To Do".into(), 0, true)]); + assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]); + assert_eq!(editor.format_state().unwrap().tags, [NoteTag::ToDo]); + let icon = + |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon; + assert_eq!( + icon(&editor), + crate::outline::TagIcon::CheckBox { checked: false } + ); + editor.format(&mut engine, Formatting::Check).unwrap(); + assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]); + assert_eq!( + icon(&editor), + crate::outline::TagIcon::CheckBox { checked: true } + ); + editor.select([at(1, 0); 2].into()).unwrap(); + editor.format(&mut engine, Formatting::Check).unwrap(); + editor.select([at(0, 1), at(1, 2)].into()).unwrap(); + editor.format(&mut engine, Formatting::Check).unwrap(); + assert_eq!(text_tags(&editor, 0), [("To Do".into(), 1, true)]); + assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]); + + editor.select([at(1, 0), at(2, 5)].into()).unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .unwrap(); + assert_eq!( + text_tags(&editor, 1), + [("Question".into(), 1, true), ("To Do".into(), 1, true)] + ); + assert_eq!(text_tags(&editor, 2), [("Question".into(), 1, true)]); + assert_eq!(editor.format_state().unwrap().tags, [NoteTag::Question]); + let questions = editor + .definitions + .values() + .filter(|definition| **definition == NoteTag::Question.definition()) + .count(); + assert_eq!(questions, 1); + editor + .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .unwrap(); + assert_eq!(text_tags(&editor, 2), []); + editor.select([at(0, 0); 2].into()).unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + assert_eq!(text_tags(&editor, 0), []); + for _ in 0..7 { + editor.undo(&mut engine).unwrap(); + } + assert!(!editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, original); + } + + #[test] + fn commands_reach_every_cell_a_selection_crosses() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["abcdef"]); + editor.select([at(0, 3); 2].into()).unwrap(); + editor.tab(&mut engine, false).unwrap(); + let table = editor.active_outline().document.clone(); + assert_eq!(table.nodes().len(), 1); + let selection = Selection::from([at(0, 1), at(1, 2)]); + editor.select(selection).unwrap(); + editor + .format(&mut engine, Formatting::Toggle(Toggle::Italic)) + .unwrap(); + assert_eq!( + runs(&editor, |format| format.italic), + [ + vec![run("a", None), run("bc", Some(true))], + vec![run("de", Some(true)), run("f", None)] + ] + ); + assert!( + editor + .format_state() + .unwrap() + .toggles + .contains(&Toggle::Italic) + ); + editor + .format(&mut engine, Formatting::Align(Alignment::Right)) + .unwrap(); + assert_eq!( + editor.format_state().unwrap().alignment, + Some(Alignment::Right) + ); + editor.select([at(1, 0); 2].into()).unwrap(); + editor.format(&mut engine, Formatting::Numbering).unwrap(); + assert_eq!(numbers(&mut engine, &editor), [None, Some(1)]); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + assert_eq!(editor.active_outline().shaped.paragraphs[1].tags.len(), 1); + assert_eq!(editor.selection(), Selection::from([at(1, 0); 2])); + for _ in 0..4 { + editor.undo(&mut engine).unwrap(); + } + assert_eq!(editor.active_outline().document, table); + assert_eq!(editor.selection(), selection); + } + + #[test] + fn formatting_lists_and_tags_survive_the_page_writer() { + use onestore::{RevisionIndex, Store, document::Document}; + const SECTION: &[u8] = + include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"); + let page = |bytes: &[u8]| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .find(|(_, page)| page.title == "Split middle") + .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 (space, source) = page(SECTION); + let mut engine = TextEngine::default(); + 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(); + let selection = Selection::from([at(0, 2), at(0, 6)]); + editor.select(selection).unwrap(); + for command in [ + Formatting::Toggle(Toggle::Bold), + Formatting::Toggle(Toggle::Superscript), + Formatting::FontSize(14.0), + Formatting::Color(Some(0x0000_00ff)), + Formatting::Highlight(Some(0x0000_ffff)), + Formatting::Align(Alignment::Center), + Formatting::Numbering, + Formatting::Tag(NoteTag::ToDo), + Formatting::Tag(NoteTag::Question), + Formatting::Check, + ] { + editor.format(&mut engine, command).unwrap(); + } + let state = editor.format_state().unwrap(); + let edited = settled(editor.page().unwrap()); + let written = onestore::PreparedEdit::page(SECTION, space, &edited, "Author").unwrap(); + let (_, reread) = page(written.as_bytes()); + let reread = settled(reread); + assert_eq!(reread.objects, edited.objects); + let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap(); + editor.focus_outline(body).unwrap(); + editor.select(selection).unwrap(); + assert_eq!(editor.format_state().unwrap(), state); + assert_eq!( + state, + FormatState { + toggles: vec![Toggle::Bold, Toggle::Superscript], + font: state.font.clone(), + font_size: Some(14.0), + alignment: Some(Alignment::Center), + bullets: false, + numbering: true, + tags: vec![NoteTag::ToDo, NoteTag::Question], + } + ); + } +} diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index dbebc44d498bbda9097f4de1847fa24e7742f4e7..7bf2456d61775d98761a23444ee41e2ea0b15f88 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -14,7 +14,7 @@ mod tests; use crate::gpu::{Paper, Viewport, page::PageScene}; use crate::{ date::DateField, - editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Selection, TextOutline}, + editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline}, layout::TextEngine, }; use draw::{ @@ -860,6 +860,16 @@ impl PageView { self.edited() } + /// A toolbar command, ignored like other edits while an input method composes or an object + /// holds focus. + pub fn format(&mut self, command: Formatting) -> Result { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok(Response::default()); + } + self.editor.format(&mut self.engine, command)?; + self.edited() + } + /// The date or time chosen after `Request::EditDate`. pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result { self.editor.change_date(&mut self.engine, timestamp, text)?; diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index dd2c07c181f8c357da7616c9063bd6fd89cf3b43..3fac287a050675decd9f33a13f66f223be53a387 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1622,3 +1622,34 @@ fn events_route_through_the_view_as_the_host_delivers_them() { let caret = view.editor.caret_outline().unwrap().origin(); assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); } + +#[test] +fn toolbar_commands_wait_for_composition_and_object_focus() { + use crate::editor::{Formatting, Toggle}; + let mut engine = TextEngine::default(); + let editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), + 240.0, + ) + .unwrap(); + let mut view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + let bold = Formatting::Toggle(Toggle::Bold); + view.editor.select_all().unwrap(); + let _ = view.compose("に".into(), None).unwrap(); + assert_eq!(view.format(bold.clone()).unwrap(), Response::default()); + let _ = view.cancel_composition().unwrap(); + view.editor.select_all().unwrap(); + view.set_object_focus(Some(ObjectFocus::ReadOnly(0))); + assert_eq!(view.format(bold.clone()).unwrap(), Response::default()); + view.set_object_focus(None); + assert!(view.format(bold).unwrap().changed); + assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]); +} diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 8e610f35f774ce54d5c71cc52325855fea6c6618..c92155446ba2bf5a49929ed7d0365d3ebed6d1f0 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -190,6 +190,9 @@ pub struct ParagraphLayout { pub text: TextLayout, /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift. pub markers: Vec<(TextLayout, [f32; 2])>, + /// A numbered paragraph's number, and whether its list restarts the count instead of + /// continuing from the previous sibling's. + pub(crate) number: Option<(u32, bool)>, pub tags: Vec, /// An equation draws in two dimensions in place of its linear text. pub math: Option, @@ -269,6 +272,76 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result) -> u32 { + previous.map_or(1, |number| number.saturating_add(1)) +} + +/// The number a paragraph laid out as `number` takes once it follows `previous`. +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. +fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { + while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { + siblings.pop(); + } + match siblings.last() { + Some((same, _)) if *same == level => siblings.pop().unwrap().1, + _ => None, + } +} + +/// `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 { + let roman = |number: u32| { + const DIGITS: [(u32, &str); 13] = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + if !(1..4000).contains(&number) { + return Err(LayoutError::UnsupportedContent); + } + let mut rest = number; + let mut text = String::new(); + for (value, digits) in DIGITS { + while rest >= value { + text.push_str(digits); + rest -= value; + } + } + Ok(text) + }; + let letter = |number: u32| { + (1..=26) + .contains(&number) + .then(|| char::from(b'A' + (number - 1) as u8).to_string()) + .ok_or(LayoutError::UnsupportedContent) + }; + match sequence.map(u32::from) { + Some(0) => Ok(number.to_string()), + Some(1) => roman(number), + Some(2) => roman(number).map(|text| text.to_lowercase()), + Some(3) => letter(number), + Some(4) => letter(number).map(|text| text.to_lowercase()), + _ => Err(LayoutError::UnsupportedContent), + } +} + fn spacing(format: &Format) -> Result<[f32; 2], LayoutError> { let spacing = [format.space_before, format.space_after].map(|space| space.unwrap_or(0.0)); if spacing.iter().any(|v| !v.is_finite() || *v < 0.0) { @@ -311,9 +384,11 @@ impl ParagraphLayout { ] } + /// Lays out a text paragraph after a sibling numbered `previous`. pub(crate) fn shape( engine: &mut TextEngine, paragraph: &PageParagraph, + previous: Option, width: f32, indents: &[f32], definitions: &BTreeMap, @@ -384,21 +459,36 @@ impl ParagraphLayout { }; let mut markers = Vec::new(); let mut marker_x = indent; + let mut number = None; for id in paragraph.lists.iter().rev() { let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; let Kind::List { font, format: Some(value), + restart, .. } = &definition.kind else { return Err(LayoutError::InvalidList); }; + let value = match value.split_once('\u{fffd}') { + Some((prefix, rest)) => { + let current = restart.unwrap_or(next(previous)); + number = Some((current, restart.is_some())); + let mut rest = rest.chars(); + format!( + "{prefix}{}{}", + numeral(rest.next(), current)?, + rest.as_str() + ) + } + None => value.clone(), + }; if value.contains('\u{fffd}') { return Err(LayoutError::UnsupportedContent); } let marker = Paragraph::new( - value.clone(), + value, Format { font: font.clone().or_else(|| definition.format.font.clone()), font_size: definition.format.font_size.or(format.font_size), @@ -453,6 +543,7 @@ impl ParagraphLayout { projection, text, markers, + number, tags, math, }) @@ -542,7 +633,12 @@ impl OutlineLayout { fixed_width: bool, depth: usize, edit: Option<&DocumentEdit>, - shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + shape: &mut impl FnMut( + &PageParagraph, + Option, + f32, + &[f32], + ) -> Result, ) -> Result { let mut result = Self::stack( nodes, @@ -552,14 +648,15 @@ impl OutlineLayout { edit, BTreeSet::new(), (0.0, None), + &mut Vec::new(), shape, )?; result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); Ok(result) } - /// Lays out `nodes` after the flow `state`, hiding the children of `hiding`; at the root, - /// keeps a block for each node. + /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of + /// `hiding`; at the root, keeps a block for each node. #[allow(clippy::too_many_arguments)] fn stack<'a>( nodes: impl Iterator, @@ -569,7 +666,13 @@ impl OutlineLayout { edit: Option<&DocumentEdit>, mut hiding: BTreeSet, mut state: (f64, Option), - shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + siblings: &mut Vec<(u32, Option)>, + shape: &mut impl FnMut( + &PageParagraph, + Option, + f32, + &[f32], + ) -> Result, ) -> Result { if !width.is_finite() || width <= 0.0 { return Err(LayoutError::InvalidWidth); @@ -587,6 +690,8 @@ impl OutlineLayout { if hidden || node.collapsed { hiding.insert(node.id); } + let previous = sibling(siblings, node.level); + let mut number = None; let mut block = Block { first: [ result.paragraphs.len(), @@ -601,7 +706,8 @@ impl OutlineLayout { if !hidden { let (space, height, flow, extent) = match &node.content { ParagraphContent::Text(_) => { - let mut paragraph = shape(node, width, indents)?; + let mut paragraph = shape(node, previous, width, indents)?; + number = paragraph.number.map(|(number, _)| number); let space = spacing(¶graph.projection.text().spans()[0].format)?; paragraph.origin[1] = top(&mut state, space); let size = paragraph.size(); @@ -657,6 +763,7 @@ impl OutlineLayout { }; let mut label = shape( &caption(node.id, file.id, name, format), + None, ATTACHMENT_WIDTH, &[0.0], )?; @@ -693,6 +800,7 @@ impl OutlineLayout { "Unsupported content", Format::default(), ), + None, w - 16.0, &[0.0], )?; @@ -728,6 +836,7 @@ impl OutlineLayout { return Err(LayoutError::InvalidSpacing); } } + siblings.push((node.level, number)); block.state = state; if depth == 0 { result.blocks.push(block); @@ -738,6 +847,14 @@ impl OutlineLayout { Ok(result) } + /// Root node `root`'s number, when it is numbered text on show. + fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<(u32, bool)> { + let block = &self.blocks[root]; + (block.metrics.is_some() && nodes[root].text().is_some()) + .then(|| self.paragraphs[block.first[0]].number) + .flatten() + } + /// The paragraphs, tables and objects of root nodes `range`. pub(crate) fn pieces(&self, range: Range) -> [Range; 3] { let first = |root: usize| { @@ -752,7 +869,7 @@ impl OutlineLayout { /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table /// holding its cell, and works out where the nodes after them move. Resizing columns or - /// showing or hiding other nodes lays out every node. + /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered. #[allow(clippy::too_many_arguments)] pub(crate) fn relayout( &self, @@ -762,7 +879,12 @@ impl OutlineLayout { indents: &[f32], width: f32, fixed_width: bool, - shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + shape: &mut impl FnMut( + &PageParagraph, + Option, + f32, + &[f32], + ) -> Result, ) -> Result { let mut range = if edit.columns.is_empty() { range @@ -804,6 +926,19 @@ impl OutlineLayout { .start .checked_sub(1) .map_or((0.0, None), |root| self.blocks[root].state); + // Each earlier level's latest paragraph, back to the nearest at the first level. + let mut siblings = Vec::new(); + let mut level = u32::MAX; + 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))); + if level <= 1 { + break; + } + } + } + siblings.reverse(); let segment = Self::stack( replacement.clone(), indents, @@ -812,10 +947,12 @@ impl OutlineLayout { Some(edit), hiding, start, - &mut |node, width, indents| { + &mut siblings, + &mut |node, previous, width, indents| { let indent = indentation(node.level, indents, width)?; if let Some((source, cached)) = cached.get(&node.id) && *source == node + && follows(cached.number, previous) == cached.number.map(|(n, _)| n) && cached.text.shaped.layout_max_advance() == width - indent && cached .markers @@ -826,7 +963,7 @@ impl OutlineLayout { result.reset_origin(indent); return Ok(result); } - shape(node, width, indents) + shape(node, previous, width, indents) }, )?; let hides = @@ -848,6 +985,23 @@ impl OutlineLayout { continue; } } + let mut renumbered = range.end; + for root in range.end..nodes.len() { + 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) { + renumbered = root + 1; + } + siblings.push((nodes[root].level, now)); + if nodes[root].level <= 1 && renumbered <= root { + break; + } + } + if renumbered > range.end { + range.end = renumbered; + continue; + } let mut state = segment.blocks.last().map_or(start, |block| block.state); let mut incoming = range .end @@ -1002,7 +1156,12 @@ impl OutlineLayout { table: &Table, depth: usize, edit: Option<&DocumentEdit>, - shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + shape: &mut impl FnMut( + &PageParagraph, + Option, + f32, + &[f32], + ) -> Result, ) -> Result { let widths = edit.and_then(|edit| edit.columns.get(&table.id)); if widths.is_some_and(|widths| widths.len() != table.columns.len()) { @@ -1135,8 +1294,8 @@ pub(crate) fn outline_layout( outline.layout.width_set_by_user == Some(true), 0, None, - &mut |node, width, indents| { - ParagraphLayout::shape(engine, node, width, indents, definitions) + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, definitions) }, ) } @@ -1373,8 +1532,15 @@ mod tests { false, 0, None, - &mut |node, width, indents| { - ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new()) + &mut |node, previous, width, indents| { + ParagraphLayout::shape( + &mut engine, + node, + previous, + width, + indents, + &BTreeMap::new(), + ) }, ) .unwrap(); @@ -1490,9 +1656,15 @@ mod tests { let mut node = paragraph(1, "Text that wraps across several lines", 2, None); let mut engine = TextEngine::default(); let mut definitions = BTreeMap::new(); - let plain = - ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) - .unwrap(); + let plain = ParagraphLayout::shape( + &mut engine, + &node, + None, + 120.0, + &[18.0, 0.0, 27.0], + &definitions, + ) + .unwrap(); node.text_mut().unwrap().tags.push(Tag { definition: Some(id), status: 3, @@ -1526,9 +1698,15 @@ mod tests { format: Format::default(), }, ); - let tagged = - ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) - .unwrap(); + let tagged = ParagraphLayout::shape( + &mut engine, + &node, + None, + 120.0, + &[18.0, 0.0, 27.0], + &definitions, + ) + .unwrap(); assert_eq!(tagged.tags[0].icon, expected); assert_eq!(tagged.tags[0].origin, [6.75, 0.0]); assert_eq!(tagged.tags[0].label, "Label"); @@ -1564,9 +1742,15 @@ mod tests { }, ); node.lists.push(list); - let tagged = - ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) - .unwrap(); + let tagged = ParagraphLayout::shape( + &mut engine, + &node, + None, + 120.0, + &[18.0, 0.0, 27.0], + &definitions, + ) + .unwrap(); assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25); assert_eq!(tagged.tags[0].origin[1], 0.0); let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { @@ -1574,7 +1758,14 @@ mod tests { }; *shape = Some(999); assert!(matches!( - ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions), + ParagraphLayout::shape( + &mut engine, + &node, + None, + 120.0, + &[18.0, 0.0, 27.0], + &definitions + ), Err(LayoutError::UnsupportedContent) )); } @@ -1623,8 +1814,15 @@ mod tests { false, 0, None, - &mut |node, width, indents| { - ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new()) + &mut |node, previous, width, indents| { + ParagraphLayout::shape( + &mut engine, + node, + previous, + width, + indents, + &BTreeMap::new(), + ) }, ) .unwrap(); @@ -1688,7 +1886,8 @@ mod tests { node.text_mut().unwrap().tags = vec![question, project, mood, action]; let mut engine = TextEngine::default(); let shaped = - ParagraphLayout::shape(&mut engine, &node, 200.0, &[0.0, 0.0], &definitions).unwrap(); + ParagraphLayout::shape(&mut engine, &node, None, 200.0, &[0.0, 0.0], &definitions) + .unwrap(); assert_eq!( shaped .tags @@ -1914,4 +2113,16 @@ mod tests { ); assert_eq!(layout.size[0], 110.0); } + + #[test] + fn numerals_follow_onenote_number_sequences() { + assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12"); + assert_eq!(numeral(Some('\u{1}'), 1994).unwrap(), "MCMXCIV"); + assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv"); + assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C"); + assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z"); + assert!(numeral(Some('\u{4}'), 27).is_err()); + assert!(numeral(Some('\u{1}'), 0).is_err()); + assert!(numeral(Some('\u{9}'), 1).is_err()); + } } -- 2.54.0