authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:25:55-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log332e865dc1d25461af433a52794ec50ebf81f388
tree227934081117e43586ef8b6937e95edfc915bae9
parent0d005581890507e46939e22ee0d991887bb3c1b7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

5 files changed, 1663 insertions(+), 54 deletions(-)

crates/canvas/src/editor.rs+46-25
......@@ -20,8 +20,10 @@ use std::{
2020
2121pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2222
23mod format;
2324pub(crate) mod page;
2425mod table;
26pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};
2527pub use page::ReadOnlyObject;
2628
2729#[derive(Debug)]
......@@ -83,6 +85,8 @@ pub struct CanvasEditor {
8385 redo: Vec<History>,
8486 composition: Option<Composition>,
8587 preferred_x: Option<f32>,
88 /// Formatting chosen at a caret for the text typed there next, until an edit.
89 pending: Option<(ExGuid, TextPosition, onestore::document::Format)>,
8690}
8791
8892/// Imported page state the editable content does not carry.
......@@ -164,8 +168,15 @@ impl TextOutline {
164168 fixed_width,
165169 0,
166170 None,
167 &mut |paragraph, width, indents| {
168 ParagraphLayout::shape(engine, paragraph, width, indents, &BTreeMap::new())
171 &mut |paragraph, previous, width, indents| {
172 ParagraphLayout::shape(
173 engine,
174 paragraph,
175 previous,
176 width,
177 indents,
178 &BTreeMap::new(),
179 )
169180 },
170181 )?;
171182 Ok(Self {
......@@ -645,6 +656,7 @@ impl CanvasEditor {
645656 redo: Vec::new(),
646657 composition: None,
647658 preferred_x: None,
659 pending: None,
648660 })
649661 }
650662
......@@ -982,6 +994,7 @@ impl CanvasEditor {
982994 redo: Vec::new(),
983995 composition: None,
984996 preferred_x: None,
997 pending: None,
985998 })
986999 }
9871000
......@@ -1069,8 +1082,9 @@ impl CanvasEditor {
10691082 Ok(())
10701083 }
10711084
1072 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {
1073 let format = match base.style {
1085 /// The format a paragraph style gives text, which clearing formatting returns to.
1086 fn style_format(&self, style: Option<ExGuid>) -> Result<onestore::document::Format, EditError> {
1087 Ok(match style {
10741088 Some(id) => self
10751089 .definitions
10761090 .get(&id)
......@@ -1081,7 +1095,11 @@ impl CanvasEditor {
10811095 .format
10821096 .clone(),
10831097 None => Default::default(),
1084 };
1098 })
1099 }
1100
1101 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {
1102 let format = self.style_format(base.style)?;
10851103 let mut node =
10861104 crate::document::node(Paragraph::new(String::new(), format.clone()), format)?;
10871105 node.level = base.level;
......@@ -1529,8 +1547,15 @@ impl CanvasEditor {
15291547 true,
15301548 0,
15311549 None,
1532 &mut |paragraph, width, indents| {
1533 ParagraphLayout::shape(engine, paragraph, width, indents, &self.definitions)
1550 &mut |paragraph, previous, width, indents| {
1551 ParagraphLayout::shape(
1552 engine,
1553 paragraph,
1554 previous,
1555 width,
1556 indents,
1557 &self.definitions,
1558 )
15341559 },
15351560 )?;
15361561 let mut layout = outline.layout.clone();
......@@ -2190,12 +2215,7 @@ impl CanvasEditor {
21902215 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
21912216 let start = self.active_outline().selection.positions[0]
21922217 .min(self.active_outline().selection.positions[1]);
2193 let format = self
2194 .active_outline()
2195 .document
2196 .paragraph(start.paragraph)
2197 .unwrap()
2198 .format_at(start.offset)?;
2218 let format = self.typing_format(start)?;
21992219 let replacement = text
22002220 .split('\n')
22012221 .map(|line| Paragraph::new(line.to_owned(), format.clone()))
......@@ -2225,9 +2245,13 @@ impl CanvasEditor {
22252245 let range = local_start
22262246 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);
22272247 let nodes = outline.document.container(container)?;
2228 crate::document::validate_flat(nodes.iter().enumerate().filter_map(|(index, node)| {
2229 (range.contains(&index) || node.text().is_some()).then_some(node)
2230 }))?;
2248 // Lists and tags move with their paragraphs; parent links and collapsed children would not.
2249 if nodes.iter().enumerate().any(|(index, node)| {
2250 (range.contains(&index) || node.text().is_some())
2251 && (node.parent.is_some() || node.collapsed)
2252 }) {
2253 return Err(EditError::UnsupportedContent.into());
2254 }
22312255 let nodes = &nodes[range.clone()];
22322256 if outdent && nodes.iter().all(|node| node.level == 1) {
22332257 return Ok(false);
......@@ -2409,6 +2433,7 @@ impl CanvasEditor {
24092433 engine: &mut TextEngine,
24102434 history: History,
24112435 ) -> Result<History, (History, EditorError)> {
2436 self.pending = None;
24122437 let valid = match &history {
24132438 History::Date(date) => self
24142439 .date
......@@ -2718,12 +2743,7 @@ impl CanvasEditor {
27182743 .try_into()
27192744 .map_err(|_| EditError::TextTooLong)?,
27202745 )?;
2721 let format = self
2722 .active_outline()
2723 .document
2724 .paragraph(range.start.paragraph)
2725 .unwrap()
2726 .format_at(range.start.offset)?;
2746 let format = self.typing_format(range.start)?;
27272747 let replacement = text
27282748 .split('\n')
27292749 .map(|part| Paragraph::new(part.to_owned(), format.clone()))
......@@ -2821,6 +2841,7 @@ impl CanvasEditor {
28212841 }
28222842
28232843 fn record_change(&mut self, change: TextChange) {
2844 self.pending = None;
28242845 if let Focus::Draft { index, .. } = self.active {
28252846 let Focus::Draft { mut outline, .. } =
28262847 std::mem::replace(&mut self.active, Focus::Outline(index))
......@@ -2878,7 +2899,7 @@ impl CanvasEditor {
28782899 &outline.indents,
28792900 outline.wrap_width(),
28802901 outline.layout.width_set_by_user == Some(true),
2881 &mut |_, _, _| Err(LayoutError::UnsupportedContent),
2902 &mut |_, _, _, _| Err(LayoutError::UnsupportedContent),
28822903 )
28832904 .expect("removing trailing text lays nothing out anew");
28842905 outline
......@@ -2983,8 +3004,8 @@ impl CanvasEditor {
29833004 &outline.indents,
29843005 outline.wrap_width(),
29853006 outline.layout.width_set_by_user == Some(true),
2986 &mut |node, width, indents| {
2987 ParagraphLayout::shape(engine, node, width, indents, &self.definitions)
3007 &mut |node, previous, width, indents| {
3008 ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions)
29883009 },
29893010 )?;
29903011 let [replaced, ..] = outline.shaped.pieces(relayout.range.clone());
crates/canvas/src/editor/format.rs created+1336
......@@ -0,0 +1,1336 @@
1//! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags.
2
3use super::*;
4use onestore::document::{Format, Kind, Tag};
5use onestore::page::ParagraphContent;
6use onestore::page::text::new_id;
7
8/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
9const AUTOMATIC: u32 = 0xff00_0000;
10/// The one checkable note tag shape the page draws.
11const CHECKBOX: u16 = 3;
12
13/// A toolbar command on the selection, or with only a caret, on the text typed there next.
14#[derive(Clone, Debug, PartialEq)]
15pub enum Formatting {
16 /// Removed where every selected character has it, applied to all of them otherwise.
17 Toggle(Toggle),
18 Font(String),
19 FontSize(f32),
20 /// A COLORREF, or `None` for automatic.
21 Color(Option<u32>),
22 /// A COLORREF, or `None` for no highlight.
23 Highlight(Option<u32>),
24 /// Returns text to its paragraph style.
25 Clear,
26 Bullets,
27 Numbering,
28 Indent,
29 Outdent,
30 Align(Alignment),
31 Tag(NoteTag),
32 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
33 Check,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum Toggle {
38 Bold,
39 Italic,
40 Underline,
41 Strikethrough,
42 /// Excludes superscript.
43 Subscript,
44 /// Excludes subscript.
45 Superscript,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Alignment {
50 Left,
51 Center,
52 Right,
53}
54
55/// OneNote 2010's default tags whose stored definition local evidence shows: To Do from a
56/// native Ctrl+1 (`corpus/paragraph-edit/reconciliation/keyboard`), Question from notebooks
57/// OneNote tagged (`corpus/private/exact-native`).
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum NoteTag {
60 ToDo,
61 Question,
62}
63
64/// What the selection shows on the toolbar.
65#[derive(Clone, Debug, PartialEq)]
66pub struct FormatState {
67 /// Attributes every selected character has.
68 pub toggles: Vec<Toggle>,
69 /// The font and size every selected character shares.
70 pub font: Option<String>,
71 pub font_size: Option<f32>,
72 /// The alignment every selected paragraph shares.
73 pub alignment: Option<Alignment>,
74 /// Whether every selected paragraph is bulleted or numbered.
75 pub bullets: bool,
76 pub numbering: bool,
77 /// Tags every selected paragraph has.
78 pub tags: Vec<NoteTag>,
79}
80
81impl Toggle {
82 const ALL: [Self; 6] = [
83 Self::Bold,
84 Self::Italic,
85 Self::Underline,
86 Self::Strikethrough,
87 Self::Subscript,
88 Self::Superscript,
89 ];
90
91 fn get(self, format: &Format) -> Option<bool> {
92 match self {
93 Self::Bold => format.bold,
94 Self::Italic => format.italic,
95 Self::Underline => format.underline,
96 Self::Strikethrough => format.strike,
97 Self::Subscript => format.subscript,
98 Self::Superscript => format.superscript,
99 }
100 }
101
102 fn slot(self, format: &mut Format) -> &mut Option<bool> {
103 match self {
104 Self::Bold => &mut format.bold,
105 Self::Italic => &mut format.italic,
106 Self::Underline => &mut format.underline,
107 Self::Strikethrough => &mut format.strike,
108 Self::Subscript => &mut format.subscript,
109 Self::Superscript => &mut format.superscript,
110 }
111 }
112}
113
114impl NoteTag {
115 const ALL: [Self; 2] = [Self::ToDo, Self::Question];
116
117 fn definition(self) -> Definition {
118 let (label, action_type, shape) = match self {
119 Self::ToDo => ("To Do", 0, CHECKBOX),
120 Self::Question => ("Question", 2, 15),
121 };
122 Definition {
123 kind: Kind::TagDefinition {
124 label: Some(label.into()),
125 action_type: Some(action_type),
126 shape: Some(shape),
127 color: None,
128 highlight: None,
129 },
130 format: Format::default(),
131 }
132 }
133}
134
135/// Where a selection starts and ends, as text leaf identity and byte offset.
136type Ends = [(ExGuid, usize); 2];
137
138/// The selection's ends and the container range holding both; a selection ending at a
139/// paragraph's start ends with the paragraph before.
140fn selected(
141 document: &TextDocument,
142 selection: Selection,
143) -> Result<(Option<ExGuid>, Range<usize>, Ends), EditError> {
144 let [anchor, focus] = selection.positions;
145 let (start, mut end) = (anchor.min(focus), anchor.max(focus));
146 if end.offset == 0 && end.paragraph > start.paragraph {
147 end.paragraph -= 1;
148 let text = document
149 .paragraph(end.paragraph)
150 .ok_or(EditError::InvalidRange)?;
151 end.offset = text.utf16_offset(text.text().len())?;
152 }
153 let (container, first, head) = document
154 .leaf(start.paragraph)
155 .ok_or(EditError::InvalidRange)?;
156 let (end_container, last, tail) = document
157 .leaf(end.paragraph)
158 .ok_or(EditError::InvalidRange)?;
159 let range = if container == end_container {
160 first..last + 1
161 } else {
162 let root = |container: Option<ExGuid>, index| {
163 container.map_or(Ok(index), |cell| document.root(cell))
164 };
165 root(container, first)?..root(end_container, last)? + 1
166 };
167 let byte = |node: &PageParagraph, offset| node.text().unwrap().text.byte_offset(offset);
168 Ok((
169 if container == end_container {
170 container
171 } else {
172 None
173 },
174 range,
175 [
176 (head.id, byte(head, start.offset)?),
177 (tail.id, byte(tail, end.offset)?),
178 ],
179 ))
180}
181
182/// The text leaves of `nodes` from `start` through `end`, each with the byte range selected.
183fn covered(
184 nodes: &[PageParagraph],
185 [start, end]: Ends,
186) -> impl Iterator<Item = (&PageParagraph, Range<usize>)> {
187 let mut done = false;
188 leaves(nodes, None)
189 .map(|(_, _, node)| node)
190 .skip_while(move |node| node.id != start.0)
191 .take_while(move |node| !std::mem::replace(&mut done, node.id == end.0))
192 .map(move |node| {
193 let from = if node.id == start.0 { start.1 } else { 0 };
194 let to = if node.id == end.0 {
195 end.1
196 } else {
197 node.text().unwrap().text.text().len()
198 };
199 (node, from..to)
200 })
201}
202
203fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
204 for node in nodes {
205 match &mut node.content {
206 ParagraphContent::Text(_) => change(node),
207 ParagraphContent::Table(table) => {
208 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
209 leaves_mut(&mut cell.paragraphs, change);
210 }
211 }
212 _ => {}
213 }
214 }
215}
216
217/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.
218fn restyle(text: &Paragraph, range: Range<usize>, change: impl Fn(&mut Format)) -> Paragraph {
219 if text.text().is_empty() {
220 let mut format = text.spans()[0].format.clone();
221 change(&mut format);
222 return Paragraph::new(String::new(), format);
223 }
224 let mut runs = Vec::new();
225 let mut start = 0;
226 for span in text.spans() {
227 let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end));
228 for (part, inside) in [
229 (start..from, false),
230 (from..to, true),
231 (to..span.end, false),
232 ] {
233 if !part.is_empty() {
234 let mut format = span.format.clone();
235 if inside {
236 change(&mut format);
237 }
238 runs.push((text.text()[part].to_owned(), format));
239 }
240 }
241 start = span.end;
242 }
243 Paragraph::from_runs(runs)
244}
245
246/// The formats of the visible characters in `range`, or of the paragraphs without text when the
247/// selection holds no characters.
248fn character_formats<'a>(
249 covered: impl Iterator<Item = (&'a PageParagraph, Range<usize>)>,
250) -> Vec<&'a Format> {
251 let mut characters = Vec::new();
252 let mut blank = Vec::new();
253 for (node, range) in covered {
254 let text = &node.text().unwrap().text;
255 if text.text().is_empty() {
256 blank.push(&text.spans()[0].format);
257 }
258 let mut start = 0;
259 for span in text.spans() {
260 if start < range.end && span.end > range.start && span.format.hidden != Some(true) {
261 characters.push(&span.format);
262 }
263 start = span.end;
264 }
265 }
266 if characters.is_empty() {
267 blank
268 } else {
269 characters
270 }
271}
272
273fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T> {
274 let first = values.next()??;
275 values
276 .all(|value| value.as_ref() == Some(&first))
277 .then_some(first)
278}
279
280/// Seconds since 1980, as note tags date themselves.
281fn time32() -> Option<u32> {
282 let now = std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .ok()?
285 .as_secs();
286 u32::try_from(now.checked_sub(315_532_800)?).ok()
287}
288
289fn tags(node: &PageParagraph) -> impl Iterator<Item = &Tag> {
290 node.tags.iter().chain(&node.text().unwrap().tags)
291}
292
293impl CanvasEditor {
294 /// The format text typed at `position` takes: the caret's pending format, else the text's
295 /// before it.
296 pub(super) fn typing_format(&self, position: TextPosition) -> Result<Format, EditError> {
297 if let Some((id, at, format)) = &self.pending
298 && *id == self.active_outline().id
299 && *at == position
300 {
301 return Ok(format.clone());
302 }
303 Ok(self
304 .active_outline()
305 .document
306 .paragraph(position.paragraph)
307 .ok_or(EditError::InvalidRange)?
308 .format_at(position.offset)?
309 .clone())
310 }
311
312 fn list(&self, node: &PageParagraph) -> Option<Formatting> {
313 match &self.definitions.get(node.lists.last()?)?.kind {
314 Kind::List { format, .. } => Some(
315 if format
316 .as_ref()
317 .is_some_and(|format| format.contains('\u{fffd}'))
318 {
319 Formatting::Numbering
320 } else {
321 Formatting::Bullets
322 },
323 ),
324 _ => None,
325 }
326 }
327
328 fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> {
329 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
330 }
331
332 pub fn format_state(&self) -> Result<FormatState, EditorError> {
333 let outline = self.active_outline();
334 let (container, range, ends) = selected(&outline.document, outline.selection)?;
335 let nodes = &outline.document.container(container)?[range];
336 let paragraphs = covered(nodes, ends)
337 .map(|(node, _)| node)
338 .collect::<Vec<_>>();
339 let caret;
340 let formats = if ends[0] == ends[1] {
341 let [anchor, focus] = outline.selection.positions;
342 caret = self.typing_format(anchor.min(focus))?;
343 vec![&caret]
344 } else {
345 character_formats(covered(nodes, ends))
346 };
347 let list = |kind| {
348 paragraphs
349 .iter()
350 .all(|node| self.list(node).as_ref() == Some(&kind))
351 };
352 Ok(FormatState {
353 toggles: Toggle::ALL
354 .into_iter()
355 .filter(|toggle| {
356 formats
357 .iter()
358 .all(|format| toggle.get(format) == Some(true))
359 })
360 .collect(),
361 font: common(formats.iter().map(|format| format.font.clone())),
362 font_size: common(formats.iter().map(|format| format.font_size)),
363 alignment: common(paragraphs.iter().map(|node| {
364 Some(
365 match node.text().unwrap().text.spans()[0].format.alignment {
366 Some(1) => Alignment::Center,
367 Some(2) => Alignment::Right,
368 _ => Alignment::Left,
369 },
370 )
371 })),
372 bullets: list(Formatting::Bullets),
373 numbering: list(Formatting::Numbering),
374 tags: NoteTag::ALL
375 .into_iter()
376 .filter(|tag| {
377 let kind = tag.definition().kind;
378 paragraphs
379 .iter()
380 .all(|node| tags(node).any(|tag| self.tag_kind(tag) == Some(&kind)))
381 })
382 .collect(),
383 })
384 }
385
386 /// Applies a toolbar command as one undo step that keeps the selection.
387 pub fn format(
388 &mut self,
389 engine: &mut TextEngine,
390 command: Formatting,
391 ) -> Result<(), EditorError> {
392 let outline = self.active_outline();
393 let (id, title, selection) = (outline.id, outline.title, outline.selection);
394 let (container, range, ends) = selected(&outline.document, selection)?;
395 let mut replacement = outline.document.container(container)?[range.clone()].to_vec();
396 let mut ranges = covered(&replacement, ends)
397 .map(|(node, range)| (node.id, range))
398 .collect::<BTreeMap<_, _>>();
399 let bases = |nodes: &[PageParagraph]| {
400 covered(nodes, ends)
401 .map(|(node, _)| Ok((node.id, self.style_format(node.style)?)))
402 .collect::<Result<BTreeMap<_, _>, EditError>>()
403 };
404 match &command {
405 Formatting::Align(_)
406 | Formatting::Bullets
407 | Formatting::Numbering
408 | Formatting::Tag(_)
409 | Formatting::Check
410 if title =>
411 {
412 return Ok(());
413 }
414 Formatting::Indent | Formatting::Outdent => {
415 self.indent(engine, command == Formatting::Outdent)?;
416 return Ok(());
417 }
418 Formatting::Align(alignment) => {
419 let bases = bases(&replacement)?;
420 leaves_mut(&mut replacement, &mut |node| {
421 if ranges.remove(&node.id).is_some() {
422 let value = match alignment {
423 Alignment::Left => bases[&node.id].alignment.map(|_| 0),
424 Alignment::Center => Some(1),
425 Alignment::Right => Some(2),
426 };
427 let text = &mut node.text_mut().unwrap().text;
428 *text = restyle(text, 0..text.text().len(), |format| {
429 format.alignment = value;
430 });
431 }
432 });
433 }
434 Formatting::Bullets | Formatting::Numbering => {
435 let remove = covered(&replacement, ends)
436 .all(|(node, _)| self.list(node).as_ref() == Some(&command));
437 let wanted = covered(&replacement, ends)
438 .map(|(node, _)| node)
439 .filter(|node| !remove && self.list(node).as_ref() != Some(&command))
440 .map(|node| {
441 let format = &node.text().unwrap().text.spans()[0].format;
442 (
443 node.id,
444 list_definition(command == Formatting::Numbering, format),
445 )
446 })
447 .collect::<Vec<_>>();
448 // A list definition belongs to one paragraph, as OneNote stores it.
449 let mut lists = BTreeMap::new();
450 for (node, definition) in wanted {
451 let id = new_id()?;
452 self.definitions.insert(id, definition);
453 lists.insert(node, id);
454 }
455 leaves_mut(&mut replacement, &mut |node| {
456 if ranges.remove(&node.id).is_some() {
457 if remove {
458 node.lists.clear();
459 } else if let Some(id) = lists.get(&node.id) {
460 node.lists = vec![*id];
461 }
462 }
463 });
464 }
465 Formatting::Tag(tag) => {
466 let definition = tag.definition();
467 let Kind::TagDefinition {
468 action_type, shape, ..
469 } = definition.kind
470 else {
471 unreachable!()
472 };
473 let existing = self
474 .definitions
475 .iter()
476 .find(|(_, other)| other.kind == definition.kind);
477 let id = match existing {
478 Some((id, _)) => *id,
479 None => {
480 let id = new_id()?;
481 self.definitions.insert(id, definition.clone());
482 id
483 }
484 };
485 let has = |node: &PageParagraph| {
486 tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind))
487 };
488 let remove = covered(&replacement, ends).all(|(node, _)| has(node));
489 let created = time32();
490 leaves_mut(&mut replacement, &mut |node| {
491 if ranges.remove(&node.id).is_none() || !remove && has(node) {
492 return;
493 }
494 let ParagraphContent::Text(text) = &mut node.content else {
495 unreachable!()
496 };
497 // An element holds one tag of each action type.
498 for tags in [&mut node.tags, &mut text.tags] {
499 tags.retain(|tag| match self.tag_kind(tag) {
500 Some(kind) if remove => *kind != definition.kind,
501 Some(Kind::TagDefinition {
502 action_type: other, ..
503 }) => *other != action_type,
504 _ => true,
505 });
506 }
507 if !remove {
508 let checkable = shape == Some(CHECKBOX);
509 // Stored newest first.
510 text.tags.insert(
511 0,
512 Tag {
513 definition: Some(id),
514 action_type: None,
515 status: u16::from(!checkable),
516 created,
517 completed: if checkable { Some(0) } else { created },
518 start: None,
519 due: None,
520 task_id: None,
521 extra_set: 0,
522 },
523 );
524 }
525 });
526 }
527 Formatting::Check => {
528 let checkable = |tag: &Tag| {
529 matches!(
530 self.tag_kind(tag),
531 Some(Kind::TagDefinition {
532 shape: Some(CHECKBOX),
533 ..
534 })
535 )
536 };
537 let checked = covered(&replacement, ends)
538 .flat_map(|(node, _)| tags(node))
539 .filter(|tag| checkable(tag))
540 .all(|tag| tag.status & 1 != 0);
541 let completed = if checked { Some(0) } else { time32() };
542 leaves_mut(&mut replacement, &mut |node| {
543 if ranges.remove(&node.id).is_none() {
544 return;
545 }
546 let ParagraphContent::Text(text) = &mut node.content else {
547 unreachable!()
548 };
549 for tag in node.tags.iter_mut().chain(&mut text.tags) {
550 if checkable(tag) && (tag.status & 1 != 0) == checked {
551 tag.status ^= 1;
552 tag.completed = completed;
553 }
554 }
555 });
556 }
557 Formatting::Toggle(_)
558 | Formatting::Font(_)
559 | Formatting::FontSize(_)
560 | Formatting::Color(_)
561 | Formatting::Highlight(_)
562 | Formatting::Clear => {
563 let on = match command {
564 Formatting::Toggle(toggle) => !character_formats(covered(&replacement, ends))
565 .iter()
566 .all(|format| toggle.get(format) == Some(true)),
567 _ => true,
568 };
569 let change = |base: &Format, format: &mut Format| match &command {
570 Formatting::Toggle(toggle) => {
571 let exclusive = match toggle {
572 Toggle::Subscript => Some(Toggle::Superscript),
573 Toggle::Superscript => Some(Toggle::Subscript),
574 _ => None,
575 };
576 for toggle in exclusive.into_iter().chain([*toggle]) {
577 *toggle.slot(format) = toggle.get(base).map(|_| false);
578 }
579 if on {
580 *toggle.slot(format) = Some(true);
581 }
582 }
583 Formatting::Font(font) => format.font = Some(font.clone()),
584 Formatting::FontSize(size) => format.font_size = Some(*size),
585 Formatting::Color(color) => {
586 format.color = color.or(base.color.map(|_| AUTOMATIC));
587 }
588 Formatting::Highlight(color) => {
589 format.highlight = color.or(base.highlight.map(|_| AUTOMATIC));
590 }
591 _ => {
592 for toggle in Toggle::ALL {
593 *toggle.slot(format) = toggle.get(base);
594 }
595 format.font.clone_from(&base.font);
596 format.font_size = base.font_size;
597 format.color = base.color;
598 format.highlight = base.highlight;
599 }
600 };
601 let bases = bases(&replacement)?;
602 if ends[0] == ends[1] {
603 let [anchor, focus] = selection.positions;
604 let caret = anchor.min(focus);
605 let mut format = self.typing_format(caret)?;
606 change(&bases[&ends[0].0], &mut format);
607 self.pending = Some((id, caret, format));
608 return Ok(());
609 }
610 leaves_mut(&mut replacement, &mut |node| {
611 if let Some(range) = ranges.remove(&node.id) {
612 let base = &bases[&node.id];
613 let text = &mut node.text_mut().unwrap().text;
614 *text = restyle(text, range, |format| change(base, format));
615 }
616 });
617 }
618 }
619 if self.active_outline().document.container(container)?[range.clone()] == replacement[..] {
620 return Ok(());
621 }
622 self.commit(
623 engine,
624 DocumentEdit {
625 columns: BTreeMap::new(),
626 container,
627 range,
628 replacement,
629 },
630 selection,
631 )
632 }
633}
634
635/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet
636/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores
637/// from its COM interface (`corpus/outline-edit/tree`).
638fn list_definition(numbering: bool, format: &Format) -> Definition {
639 let font_size = Some(format.font_size.unwrap_or(11.0));
640 if numbering {
641 Definition {
642 kind: Kind::List {
643 font: None,
644 format: Some("\u{fffd}\u{0}.".into()),
645 restart: None,
646 bullet: None,
647 },
648 format: Format {
649 bold: Some(false),
650 italic: Some(false),
651 font: format.font.clone(),
652 font_size,
653 color: Some(AUTOMATIC),
654 language: format.language,
655 ..Format::default()
656 },
657 }
658 } else {
659 Definition {
660 kind: Kind::List {
661 font: Some("Calibri".into()),
662 format: Some("\u{2022}".into()),
663 restart: None,
664 bullet: Some(1),
665 },
666 format: Format {
667 font_size,
668 color: Some(AUTOMATIC),
669 ..Format::default()
670 },
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 fn at(paragraph: usize, offset: u32) -> TextPosition {
680 TextPosition { paragraph, offset }
681 }
682
683 fn calibri() -> Format {
684 Format {
685 font: Some("Calibri".into()),
686 font_size: Some(11.0),
687 ..Format::default()
688 }
689 }
690
691 fn editor(engine: &mut TextEngine, paragraphs: Vec<Paragraph>) -> CanvasEditor {
692 CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 300.0).unwrap()
693 }
694
695 fn plain(engine: &mut TextEngine, texts: &[&str]) -> CanvasEditor {
696 let paragraphs = texts
697 .iter()
698 .map(|text| Paragraph::new((*text).into(), calibri()))
699 .collect();
700 editor(engine, paragraphs)
701 }
702
703 /// Each paragraph's runs as text and the value `field` reads from their format.
704 fn runs<T>(editor: &CanvasEditor, field: impl Fn(&Format) -> T) -> Vec<Vec<(String, T)>> {
705 editor
706 .active_outline()
707 .document
708 .paragraphs()
709 .map(|paragraph| {
710 let mut start = 0;
711 paragraph
712 .spans()
713 .iter()
714 .map(|span| {
715 let text = paragraph.text()[start..span.end].to_owned();
716 start = span.end;
717 (text, field(&span.format))
718 })
719 .collect()
720 })
721 .collect()
722 }
723
724 fn run(text: &str, value: Option<bool>) -> (String, Option<bool>) {
725 (text.into(), value)
726 }
727
728 #[test]
729 fn toggles_apply_unless_every_character_has_them_as_one_undo_step() {
730 let mut engine = TextEngine::default();
731 let mut editor = editor(
732 &mut engine,
733 vec![
734 Paragraph::from_runs([
735 ("plain ".into(), calibri()),
736 (
737 "bold".into(),
738 Format {
739 bold: Some(true),
740 ..calibri()
741 },
742 ),
743 ]),
744 Paragraph::new("next".into(), calibri()),
745 ],
746 );
747 let original = editor.active_outline().document.clone();
748 let selection = Selection::from([at(1, 2), at(0, 3)]);
749 editor.select(selection).unwrap();
750 let bold = |editor: &CanvasEditor| runs(editor, |format| format.bold);
751 editor
752 .format(&mut engine, Formatting::Toggle(Toggle::Bold))
753 .unwrap();
754 assert_eq!(
755 bold(&editor),
756 [
757 vec![run("pla", None), run("in bold", Some(true))],
758 vec![run("ne", Some(true)), run("xt", None)]
759 ]
760 );
761 assert_eq!(editor.selection(), selection);
762 assert!(
763 editor
764 .format_state()
765 .unwrap()
766 .toggles
767 .contains(&Toggle::Bold)
768 );
769 editor
770 .format(&mut engine, Formatting::Toggle(Toggle::Bold))
771 .unwrap();
772 assert_eq!(
773 bold(&editor),
774 [vec![run("plain bold", None)], vec![run("next", None)]]
775 );
776 assert!(editor.undo(&mut engine).unwrap());
777 assert!(editor.undo(&mut engine).unwrap());
778 assert_eq!(editor.active_outline().document, original);
779 assert_eq!(editor.selection(), selection);
780 assert!(!editor.undo(&mut engine).unwrap());
781 assert!(editor.redo(&mut engine).unwrap());
782 assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]);
783 }
784
785 #[test]
786 fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() {
787 let mut engine = TextEngine::default();
788 let mut editor = plain(&mut engine, &["ab"]);
789 editor.select([at(0, 1); 2].into()).unwrap();
790 for toggle in [Toggle::Bold, Toggle::Superscript, Toggle::Subscript] {
791 editor
792 .format(&mut engine, Formatting::Toggle(toggle))
793 .unwrap();
794 }
795 assert!(!editor.undo(&mut engine).unwrap());
796 assert_eq!(
797 editor.format_state().unwrap().toggles,
798 [Toggle::Bold, Toggle::Subscript]
799 );
800 editor.insert(&mut engine, "x").unwrap();
801 let typed = Format {
802 bold: Some(true),
803 subscript: Some(true),
804 ..calibri()
805 };
806 assert_eq!(
807 runs(&editor, Clone::clone),
808 [vec![
809 ("a".into(), calibri()),
810 ("x".into(), typed.clone()),
811 ("b".into(), calibri())
812 ]]
813 );
814 editor
815 .format(&mut engine, Formatting::Toggle(Toggle::Italic))
816 .unwrap();
817 editor.select([at(0, 0); 2].into()).unwrap();
818 editor.insert(&mut engine, "y").unwrap();
819 assert_eq!(runs(&editor, |format| format.italic)[0][0], run("ya", None));
820
821 editor
822 .format(&mut engine, Formatting::FontSize(20.0))
823 .unwrap();
824 editor.compose(&mut engine, "k".into(), 1..1).unwrap();
825 editor.compose(&mut engine, "kl".into(), 2..2).unwrap();
826 editor.commit_text(&mut engine, "kl".into()).unwrap();
827 assert_eq!(
828 runs(&editor, |format| format.font_size)[0][..2],
829 [("y".into(), Some(11.0)), ("kl".into(), Some(20.0))]
830 );
831 editor.undo(&mut engine).unwrap();
832 assert_eq!(
833 editor
834 .active_outline()
835 .document
836 .paragraphs()
837 .next()
838 .unwrap()
839 .text(),
840 "yaxb"
841 );
842 }
843
844 #[test]
845 fn clearing_and_removing_attributes_return_to_the_paragraph_style() {
846 let mut engine = TextEngine::default();
847 let style = new_id().unwrap();
848 let heading = Format {
849 bold: Some(true),
850 color: Some(0x0012_3456),
851 font: Some("Georgia".into()),
852 font_size: Some(18.0),
853 ..Format::default()
854 };
855 let mut nodes = TextDocument::new(vec![Paragraph::new("Heading".into(), heading.clone())])
856 .unwrap()
857 .nodes()
858 .to_vec();
859 nodes[0].style = Some(style);
860 let outline = Outline {
861 id: new_id().unwrap(),
862 title: false,
863 min_width: None,
864 layout: onestore::document::Layout {
865 max_width: Some(300.0),
866 ..Default::default()
867 },
868 indents: vec![18.0, 0.0, 27.0, 27.0],
869 paragraphs: nodes,
870 unsupported: Vec::new(),
871 };
872 let definitions = BTreeMap::from([(
873 style,
874 Definition {
875 kind: Kind::Style {
876 name: Some("h1".into()),
877 },
878 format: heading.clone(),
879 },
880 )]);
881 let mut editor =
882 CanvasEditor::from_outlines(&mut engine, vec![outline], definitions).unwrap();
883 editor.select_all().unwrap();
884 let state = editor.format_state().unwrap();
885 assert_eq!(state.toggles, [Toggle::Bold]);
886 assert_eq!(
887 (state.font.as_deref(), state.font_size, state.alignment),
888 (Some("Georgia"), Some(18.0), Some(Alignment::Left))
889 );
890 for command in [
891 Formatting::Toggle(Toggle::Bold),
892 Formatting::Color(None),
893 Formatting::Highlight(Some(0x0000_ffff)),
894 Formatting::Font("Arial".into()),
895 Formatting::Align(Alignment::Center),
896 ] {
897 editor.format(&mut engine, command).unwrap();
898 }
899 editor.select([at(0, 0), at(0, 4)].into()).unwrap();
900 editor
901 .format(&mut engine, Formatting::FontSize(9.0))
902 .unwrap();
903 editor.select_all().unwrap();
904 assert_eq!(editor.format_state().unwrap().font_size, None);
905 let format = editor
906 .active_outline()
907 .document
908 .paragraph(0)
909 .unwrap()
910 .spans()[1]
911 .format
912 .clone();
913 assert_eq!(
914 format,
915 Format {
916 bold: Some(false),
917 color: Some(AUTOMATIC),
918 highlight: Some(0x0000_ffff),
919 font: Some("Arial".into()),
920 alignment: Some(1),
921 ..heading.clone()
922 }
923 );
924 editor.format(&mut engine, Formatting::Clear).unwrap();
925 assert_eq!(
926 runs(&editor, Clone::clone),
927 [vec![(
928 "Heading".into(),
929 Format {
930 alignment: Some(1),
931 ..heading
932 }
933 )]]
934 );
935 editor
936 .format(&mut engine, Formatting::Highlight(None))
937 .unwrap();
938 for _ in 0..7 {
939 assert!(editor.undo(&mut engine).unwrap());
940 }
941 assert!(!editor.undo(&mut engine).unwrap());
942 }
943
944 /// Each paragraph's number as laid out, checked against laying the outline out afresh.
945 fn numbers(engine: &mut TextEngine, editor: &CanvasEditor) -> Vec<Option<u32>> {
946 let outline = editor.active_outline();
947 let fresh = OutlineLayout::flow(
948 outline.document.nodes().iter(),
949 &outline.indents,
950 outline.wrap_width(),
951 true,
952 0,
953 None,
954 &mut |node, previous, width, indents| {
955 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
956 },
957 )
958 .unwrap();
959 let laid = |layout: &OutlineLayout| {
960 layout
961 .paragraphs
962 .iter()
963 .map(|paragraph| {
964 let markers = paragraph.markers.iter().map(|(marker, origin)| {
965 (*origin, marker.lines().next().unwrap().0.metrics().advance)
966 });
967 (paragraph.number, markers.collect::<Vec<_>>())
968 })
969 .collect::<Vec<_>>()
970 };
971 assert_eq!(laid(&outline.shaped), laid(&fresh));
972 outline
973 .shaped
974 .paragraphs
975 .iter()
976 .map(|paragraph| paragraph.number.map(|(number, _)| number))
977 .collect()
978 }
979
980 #[test]
981 fn lists_toggle_switch_kind_and_renumber_only_what_changes() {
982 let mut engine = TextEngine::default();
983 let mut editor = plain(&mut engine, &["one", "two", "three", "four"]);
984 let original = editor.active_outline().document.clone();
985 let all = Selection::from([at(0, 0), at(3, 4)]);
986 editor.select(all).unwrap();
987 editor.format(&mut engine, Formatting::Bullets).unwrap();
988 let state = editor.format_state().unwrap();
989 assert!(state.bullets && !state.numbering);
990 assert_eq!(numbers(&mut engine, &editor), [None; 4]);
991 let bullet = editor.active_outline().document.nodes()[0].lists[0];
992 assert_eq!(
993 editor.definitions[&bullet],
994 list_definition(false, &calibri())
995 );
996 editor.format(&mut engine, Formatting::Numbering).unwrap();
997 let state = editor.format_state().unwrap();
998 assert!(!state.bullets && state.numbering);
999 assert_eq!(
1000 numbers(&mut engine, &editor),
1001 [Some(1), Some(2), Some(3), Some(4)]
1002 );
1003 let untouched = editor.active_outline().shaped.paragraphs[0]
1004 .text
1005 .shaped
1006 .styles()
1007 .as_ptr();
1008 editor.select([at(1, 1); 2].into()).unwrap();
1009 editor.format(&mut engine, Formatting::Numbering).unwrap();
1010 assert_eq!(
1011 numbers(&mut engine, &editor),
1012 [Some(1), None, Some(1), Some(2)]
1013 );
1014 assert_eq!(
1015 editor.active_outline().shaped.paragraphs[0]
1016 .text
1017 .shaped
1018 .styles()
1019 .as_ptr(),
1020 untouched
1021 );
1022 editor.undo(&mut engine).unwrap();
1023 editor.select([at(2, 0); 2].into()).unwrap();
1024 editor.format(&mut engine, Formatting::Indent).unwrap();
1025 assert_eq!(
1026 numbers(&mut engine, &editor),
1027 [Some(1), Some(2), Some(1), Some(3)]
1028 );
1029 assert_eq!(editor.selection(), Selection::from([at(2, 0); 2]));
1030 editor.format(&mut engine, Formatting::Bullets).unwrap();
1031 assert_eq!(
1032 numbers(&mut engine, &editor),
1033 [Some(1), Some(2), None, Some(3)]
1034 );
1035 assert!(editor.format_state().unwrap().bullets);
1036 editor.format(&mut engine, Formatting::Outdent).unwrap();
1037 assert_eq!(
1038 numbers(&mut engine, &editor),
1039 [Some(1), Some(2), None, Some(1)]
1040 );
1041 editor.select(all).unwrap();
1042 let state = editor.format_state().unwrap();
1043 assert!(!state.bullets && !state.numbering);
1044 for _ in 0..6 {
1045 editor.undo(&mut engine).unwrap();
1046 }
1047 assert_eq!(editor.active_outline().document, original);
1048 assert_eq!(numbers(&mut engine, &editor), [None; 4]);
1049 }
1050
1051 #[test]
1052 fn numbering_follows_stored_sequences_restarts_and_nesting() {
1053 let mut engine = TextEngine::default();
1054 let mut editor = plain(&mut engine, &["a", "b", "c", "d", "e"]);
1055 let mut nodes = editor.active_outline().document.nodes().to_vec();
1056 for (node, (sequence, restart, level)) in nodes.iter_mut().zip([
1057 ('\u{1}', None, 1),
1058 ('\u{1}', None, 1),
1059 ('\u{4}', None, 2),
1060 ('\u{1}', Some(9), 1),
1061 ('\u{0}', None, 1),
1062 ]) {
1063 let id = new_id().unwrap();
1064 let mut definition = list_definition(true, &calibri());
1065 let Kind::List {
1066 format,
1067 restart: value,
1068 ..
1069 } = &mut definition.kind
1070 else {
1071 unreachable!()
1072 };
1073 *format = Some(format!("(\u{fffd}{sequence})"));
1074 *value = restart;
1075 editor.definitions.insert(id, definition);
1076 node.lists = vec![id];
1077 node.level = level;
1078 }
1079 let outline = Outline {
1080 paragraphs: nodes,
1081 ..editor.active_outline().snapshot()
1082 };
1083 let editor =
1084 CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions).unwrap();
1085 assert_eq!(
1086 numbers(&mut engine, &editor),
1087 [Some(1), Some(2), Some(1), Some(9), Some(10)]
1088 );
1089 }
1090
1091 fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> {
1092 let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap();
1093 tags(node)
1094 .map(|tag| {
1095 let Some(Kind::TagDefinition { label, .. }) = editor.tag_kind(tag) else {
1096 panic!("{tag:?}")
1097 };
1098 let settled = tag.completed
1099 == if tag.status & 1 == 0 {
1100 Some(0)
1101 } else {
1102 tag.created
1103 };
1104 (
1105 label.clone().unwrap(),
1106 tag.status,
1107 settled || tag.status & 1 == 1,
1108 )
1109 })
1110 .collect()
1111 }
1112
1113 #[test]
1114 fn tags_toggle_check_and_replace_tags_of_their_action_type() {
1115 let mut engine = TextEngine::default();
1116 let mut editor = plain(&mut engine, &["task", "other", "music"]);
1117 let music = new_id().unwrap();
1118 editor.definitions.insert(
1119 music,
1120 Definition {
1121 kind: Kind::TagDefinition {
1122 label: Some("Music".into()),
1123 action_type: Some(2),
1124 shape: Some(121),
1125 color: None,
1126 highlight: None,
1127 },
1128 format: Format::default(),
1129 },
1130 );
1131 let mut nodes = editor.active_outline().document.nodes().to_vec();
1132 nodes[2].tags.push(Tag {
1133 definition: Some(music),
1134 action_type: None,
1135 status: 1,
1136 created: Some(1),
1137 completed: Some(1),
1138 start: None,
1139 due: None,
1140 task_id: None,
1141 extra_set: 0,
1142 });
1143 let outline = Outline {
1144 paragraphs: nodes,
1145 ..editor.active_outline().snapshot()
1146 };
1147 let mut editor =
1148 CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions).unwrap();
1149 let original = editor.active_outline().document.clone();
1150 editor.select([at(0, 1), at(1, 2)].into()).unwrap();
1151 editor
1152 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1153 .unwrap();
1154 assert_eq!(text_tags(&editor, 0), [("To Do".into(), 0, true)]);
1155 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]);
1156 assert_eq!(editor.format_state().unwrap().tags, [NoteTag::ToDo]);
1157 let icon =
1158 |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon;
1159 assert_eq!(
1160 icon(&editor),
1161 crate::outline::TagIcon::CheckBox { checked: false }
1162 );
1163 editor.format(&mut engine, Formatting::Check).unwrap();
1164 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);
1165 assert_eq!(
1166 icon(&editor),
1167 crate::outline::TagIcon::CheckBox { checked: true }
1168 );
1169 editor.select([at(1, 0); 2].into()).unwrap();
1170 editor.format(&mut engine, Formatting::Check).unwrap();
1171 editor.select([at(0, 1), at(1, 2)].into()).unwrap();
1172 editor.format(&mut engine, Formatting::Check).unwrap();
1173 assert_eq!(text_tags(&editor, 0), [("To Do".into(), 1, true)]);
1174 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);
1175
1176 editor.select([at(1, 0), at(2, 5)].into()).unwrap();
1177 editor
1178 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1179 .unwrap();
1180 assert_eq!(
1181 text_tags(&editor, 1),
1182 [("Question".into(), 1, true), ("To Do".into(), 1, true)]
1183 );
1184 assert_eq!(text_tags(&editor, 2), [("Question".into(), 1, true)]);
1185 assert_eq!(editor.format_state().unwrap().tags, [NoteTag::Question]);
1186 let questions = editor
1187 .definitions
1188 .values()
1189 .filter(|definition| **definition == NoteTag::Question.definition())
1190 .count();
1191 assert_eq!(questions, 1);
1192 editor
1193 .format(&mut engine, Formatting::Tag(NoteTag::Question))
1194 .unwrap();
1195 assert_eq!(text_tags(&editor, 2), []);
1196 editor.select([at(0, 0); 2].into()).unwrap();
1197 editor
1198 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1199 .unwrap();
1200 assert_eq!(text_tags(&editor, 0), []);
1201 for _ in 0..7 {
1202 editor.undo(&mut engine).unwrap();
1203 }
1204 assert!(!editor.undo(&mut engine).unwrap());
1205 assert_eq!(editor.active_outline().document, original);
1206 }
1207
1208 #[test]
1209 fn commands_reach_every_cell_a_selection_crosses() {
1210 let mut engine = TextEngine::default();
1211 let mut editor = plain(&mut engine, &["abcdef"]);
1212 editor.select([at(0, 3); 2].into()).unwrap();
1213 editor.tab(&mut engine, false).unwrap();
1214 let table = editor.active_outline().document.clone();
1215 assert_eq!(table.nodes().len(), 1);
1216 let selection = Selection::from([at(0, 1), at(1, 2)]);
1217 editor.select(selection).unwrap();
1218 editor
1219 .format(&mut engine, Formatting::Toggle(Toggle::Italic))
1220 .unwrap();
1221 assert_eq!(
1222 runs(&editor, |format| format.italic),
1223 [
1224 vec![run("a", None), run("bc", Some(true))],
1225 vec![run("de", Some(true)), run("f", None)]
1226 ]
1227 );
1228 assert!(
1229 editor
1230 .format_state()
1231 .unwrap()
1232 .toggles
1233 .contains(&Toggle::Italic)
1234 );
1235 editor
1236 .format(&mut engine, Formatting::Align(Alignment::Right))
1237 .unwrap();
1238 assert_eq!(
1239 editor.format_state().unwrap().alignment,
1240 Some(Alignment::Right)
1241 );
1242 editor.select([at(1, 0); 2].into()).unwrap();
1243 editor.format(&mut engine, Formatting::Numbering).unwrap();
1244 assert_eq!(numbers(&mut engine, &editor), [None, Some(1)]);
1245 editor
1246 .format(&mut engine, Formatting::Tag(NoteTag::ToDo))
1247 .unwrap();
1248 assert_eq!(editor.active_outline().shaped.paragraphs[1].tags.len(), 1);
1249 assert_eq!(editor.selection(), Selection::from([at(1, 0); 2]));
1250 for _ in 0..4 {
1251 editor.undo(&mut engine).unwrap();
1252 }
1253 assert_eq!(editor.active_outline().document, table);
1254 assert_eq!(editor.selection(), selection);
1255 }
1256
1257 #[test]
1258 fn formatting_lists_and_tags_survive_the_page_writer() {
1259 use onestore::{RevisionIndex, Store, document::Document};
1260 const SECTION: &[u8] =
1261 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one");
1262 let page = |bytes: &[u8]| {
1263 let store = Store::parse(bytes).unwrap();
1264 let index = RevisionIndex::parse(&store).unwrap();
1265 let document = Document::parse(&index).unwrap();
1266 document
1267 .pages()
1268 .unwrap()
1269 .into_iter()
1270 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
1271 .find(|(_, page)| page.title == "Split middle")
1272 .unwrap()
1273 };
1274 // Tags keep their place in the element's property arena only once read.
1275 let settled = |mut page: Page| {
1276 for object in &mut page.objects {
1277 if let PageObject::Outline(outline) = object {
1278 for paragraph in &mut outline.paragraphs {
1279 for tag in &mut paragraph.text_mut().unwrap().tags {
1280 tag.extra_set = 0;
1281 }
1282 }
1283 }
1284 }
1285 page
1286 };
1287 let (space, source) = page(SECTION);
1288 let mut engine = TextEngine::default();
1289 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
1290 let body = editor
1291 .outlines()
1292 .iter()
1293 .find(|outline| !outline.title)
1294 .unwrap()
1295 .id;
1296 editor.focus_outline(body).unwrap();
1297 let selection = Selection::from([at(0, 2), at(0, 6)]);
1298 editor.select(selection).unwrap();
1299 for command in [
1300 Formatting::Toggle(Toggle::Bold),
1301 Formatting::Toggle(Toggle::Superscript),
1302 Formatting::FontSize(14.0),
1303 Formatting::Color(Some(0x0000_00ff)),
1304 Formatting::Highlight(Some(0x0000_ffff)),
1305 Formatting::Align(Alignment::Center),
1306 Formatting::Numbering,
1307 Formatting::Tag(NoteTag::ToDo),
1308 Formatting::Tag(NoteTag::Question),
1309 Formatting::Check,
1310 ] {
1311 editor.format(&mut engine, command).unwrap();
1312 }
1313 let state = editor.format_state().unwrap();
1314 let edited = settled(editor.page().unwrap());
1315 let written = onestore::PreparedEdit::page(SECTION, space, &edited, "Author").unwrap();
1316 let (_, reread) = page(written.as_bytes());
1317 let reread = settled(reread);
1318 assert_eq!(reread.objects, edited.objects);
1319 let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap();
1320 editor.focus_outline(body).unwrap();
1321 editor.select(selection).unwrap();
1322 assert_eq!(editor.format_state().unwrap(), state);
1323 assert_eq!(
1324 state,
1325 FormatState {
1326 toggles: vec![Toggle::Bold, Toggle::Superscript],
1327 font: state.font.clone(),
1328 font_size: Some(14.0),
1329 alignment: Some(Alignment::Center),
1330 bullets: false,
1331 numbering: true,
1332 tags: vec![NoteTag::ToDo, NoteTag::Question],
1333 }
1334 );
1335 }
1336}
crates/canvas/src/interaction/mod.rs+11-1
......@@ -14,7 +14,7 @@ mod tests;
1414use crate::gpu::{Paper, Viewport, page::PageScene};
1515use crate::{
1616 date::DateField,
17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Selection, TextOutline},
17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline},
1818 layout::TextEngine,
1919};
2020use draw::{
......@@ -860,6 +860,16 @@ impl PageView {
860860 self.edited()
861861 }
862862
863 /// A toolbar command, ignored like other edits while an input method composes or an object
864 /// holds focus.
865 pub fn format(&mut self, command: Formatting) -> Result<Response> {
866 if !self.accepts_text() || self.editor.marked_range().is_some() {
867 return Ok(Response::default());
868 }
869 self.editor.format(&mut self.engine, command)?;
870 self.edited()
871 }
872
863873 /// The date or time chosen after `Request::EditDate`.
864874 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
865875 self.editor.change_date(&mut self.engine, timestamp, text)?;
crates/canvas/src/interaction/tests.rs+31
......@@ -1622,3 +1622,34 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16221622 let caret = view.editor.caret_outline().unwrap().origin();
16231623 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));
16241624}
1625
1626#[test]
1627fn toolbar_commands_wait_for_composition_and_object_focus() {
1628 use crate::editor::{Formatting, Toggle};
1629 let mut engine = TextEngine::default();
1630 let editor = CanvasEditor::new(
1631 &mut engine,
1632 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
1633 240.0,
1634 )
1635 .unwrap();
1636 let mut view = PageView::new(
1637 editor,
1638 engine,
1639 None,
1640 [800, 600],
1641 1.0,
1642 Duration::from_millis(500),
1643 );
1644 let bold = Formatting::Toggle(Toggle::Bold);
1645 view.editor.select_all().unwrap();
1646 let _ = view.compose("に".into(), None).unwrap();
1647 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
1648 let _ = view.cancel_composition().unwrap();
1649 view.editor.select_all().unwrap();
1650 view.set_object_focus(Some(ObjectFocus::ReadOnly(0)));
1651 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
1652 view.set_object_focus(None);
1653 assert!(view.format(bold).unwrap().changed);
1654 assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]);
1655}
crates/canvas/src/outline.rs+239-28
......@@ -190,6 +190,9 @@ pub struct ParagraphLayout {
190190 pub text: TextLayout,
191191 /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift.
192192 pub markers: Vec<(TextLayout, [f32; 2])>,
193 /// A numbered paragraph's number, and whether its list restarts the count instead of
194 /// continuing from the previous sibling's.
195 pub(crate) number: Option<(u32, bool)>,
193196 pub tags: Vec<ParagraphTag>,
194197 /// An equation draws in two dimensions in place of its linear text.
195198 pub math: Option<crate::math::MathLayout>,
......@@ -269,6 +272,76 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32
269272 Ok(indent)
270273}
271274
275/// The number after a previous sibling's, or the first when the sibling is not numbered.
276fn next(previous: Option<u32>) -> u32 {
277 previous.map_or(1, |number| number.saturating_add(1))
278}
279
280/// The number a paragraph laid out as `number` takes once it follows `previous`.
281fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {
282 number.map(|(number, restart)| if restart { number } else { next(previous) })
283}
284
285/// Removes deeper paragraphs' entries from a flow's `(level, number)` stack of the latest
286/// paragraph at each level, and the previous sibling's at `level`, returning that one's number.
287fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
288 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {
289 siblings.pop();
290 }
291 match siblings.last() {
292 Some((same, _)) if *same == level => siblings.pop().unwrap().1,
293 _ => None,
294 }
295}
296
297/// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4
298/// upper and lower letters, as OneNote 2010's `numberSequence`.
299fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
300 let roman = |number: u32| {
301 const DIGITS: [(u32, &str); 13] = [
302 (1000, "M"),
303 (900, "CM"),
304 (500, "D"),
305 (400, "CD"),
306 (100, "C"),
307 (90, "XC"),
308 (50, "L"),
309 (40, "XL"),
310 (10, "X"),
311 (9, "IX"),
312 (5, "V"),
313 (4, "IV"),
314 (1, "I"),
315 ];
316 if !(1..4000).contains(&number) {
317 return Err(LayoutError::UnsupportedContent);
318 }
319 let mut rest = number;
320 let mut text = String::new();
321 for (value, digits) in DIGITS {
322 while rest >= value {
323 text.push_str(digits);
324 rest -= value;
325 }
326 }
327 Ok(text)
328 };
329 let letter = |number: u32| {
330 (1..=26)
331 .contains(&number)
332 .then(|| char::from(b'A' + (number - 1) as u8).to_string())
333 .ok_or(LayoutError::UnsupportedContent)
334 };
335 match sequence.map(u32::from) {
336 Some(0) => Ok(number.to_string()),
337 Some(1) => roman(number),
338 Some(2) => roman(number).map(|text| text.to_lowercase()),
339 Some(3) => letter(number),
340 Some(4) => letter(number).map(|text| text.to_lowercase()),
341 _ => Err(LayoutError::UnsupportedContent),
342 }
343}
344
272345fn spacing(format: &Format) -> Result<[f32; 2], LayoutError> {
273346 let spacing = [format.space_before, format.space_after].map(|space| space.unwrap_or(0.0));
274347 if spacing.iter().any(|v| !v.is_finite() || *v < 0.0) {
......@@ -311,9 +384,11 @@ impl ParagraphLayout {
311384 ]
312385 }
313386
387 /// Lays out a text paragraph after a sibling numbered `previous`.
314388 pub(crate) fn shape(
315389 engine: &mut TextEngine,
316390 paragraph: &PageParagraph,
391 previous: Option<u32>,
317392 width: f32,
318393 indents: &[f32],
319394 definitions: &BTreeMap<ExGuid, Definition>,
......@@ -384,21 +459,36 @@ impl ParagraphLayout {
384459 };
385460 let mut markers = Vec::new();
386461 let mut marker_x = indent;
462 let mut number = None;
387463 for id in paragraph.lists.iter().rev() {
388464 let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?;
389465 let Kind::List {
390466 font,
391467 format: Some(value),
468 restart,
392469 ..
393470 } = &definition.kind
394471 else {
395472 return Err(LayoutError::InvalidList);
396473 };
474 let value = match value.split_once('\u{fffd}') {
475 Some((prefix, rest)) => {
476 let current = restart.unwrap_or(next(previous));
477 number = Some((current, restart.is_some()));
478 let mut rest = rest.chars();
479 format!(
480 "{prefix}{}{}",
481 numeral(rest.next(), current)?,
482 rest.as_str()
483 )
484 }
485 None => value.clone(),
486 };
397487 if value.contains('\u{fffd}') {
398488 return Err(LayoutError::UnsupportedContent);
399489 }
400490 let marker = Paragraph::new(
401 value.clone(),
491 value,
402492 Format {
403493 font: font.clone().or_else(|| definition.format.font.clone()),
404494 font_size: definition.format.font_size.or(format.font_size),
......@@ -453,6 +543,7 @@ impl ParagraphLayout {
453543 projection,
454544 text,
455545 markers,
546 number,
456547 tags,
457548 math,
458549 })
......@@ -542,7 +633,12 @@ impl OutlineLayout {
542633 fixed_width: bool,
543634 depth: usize,
544635 edit: Option<&DocumentEdit>,
545 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
636 shape: &mut impl FnMut(
637 &PageParagraph,
638 Option<u32>,
639 f32,
640 &[f32],
641 ) -> Result<ParagraphLayout, LayoutError>,
546642 ) -> Result<Self, LayoutError> {
547643 let mut result = Self::stack(
548644 nodes,
......@@ -552,14 +648,15 @@ impl OutlineLayout {
552648 edit,
553649 BTreeSet::new(),
554650 (0.0, None),
651 &mut Vec::new(),
555652 shape,
556653 )?;
557654 result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width());
558655 Ok(result)
559656 }
560657
561 /// Lays out `nodes` after the flow `state`, hiding the children of `hiding`; at the root,
562 /// keeps a block for each node.
658 /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of
659 /// `hiding`; at the root, keeps a block for each node.
563660 #[allow(clippy::too_many_arguments)]
564661 fn stack<'a>(
565662 nodes: impl Iterator<Item = &'a PageParagraph>,
......@@ -569,7 +666,13 @@ impl OutlineLayout {
569666 edit: Option<&DocumentEdit>,
570667 mut hiding: BTreeSet<ExGuid>,
571668 mut state: (f64, Option<f32>),
572 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
669 siblings: &mut Vec<(u32, Option<u32>)>,
670 shape: &mut impl FnMut(
671 &PageParagraph,
672 Option<u32>,
673 f32,
674 &[f32],
675 ) -> Result<ParagraphLayout, LayoutError>,
573676 ) -> Result<Self, LayoutError> {
574677 if !width.is_finite() || width <= 0.0 {
575678 return Err(LayoutError::InvalidWidth);
......@@ -587,6 +690,8 @@ impl OutlineLayout {
587690 if hidden || node.collapsed {
588691 hiding.insert(node.id);
589692 }
693 let previous = sibling(siblings, node.level);
694 let mut number = None;
590695 let mut block = Block {
591696 first: [
592697 result.paragraphs.len(),
......@@ -601,7 +706,8 @@ impl OutlineLayout {
601706 if !hidden {
602707 let (space, height, flow, extent) = match &node.content {
603708 ParagraphContent::Text(_) => {
604 let mut paragraph = shape(node, width, indents)?;
709 let mut paragraph = shape(node, previous, width, indents)?;
710 number = paragraph.number.map(|(number, _)| number);
605711 let space = spacing(&paragraph.projection.text().spans()[0].format)?;
606712 paragraph.origin[1] = top(&mut state, space);
607713 let size = paragraph.size();
......@@ -657,6 +763,7 @@ impl OutlineLayout {
657763 };
658764 let mut label = shape(
659765 &caption(node.id, file.id, name, format),
766 None,
660767 ATTACHMENT_WIDTH,
661768 &[0.0],
662769 )?;
......@@ -693,6 +800,7 @@ impl OutlineLayout {
693800 "Unsupported content",
694801 Format::default(),
695802 ),
803 None,
696804 w - 16.0,
697805 &[0.0],
698806 )?;
......@@ -728,6 +836,7 @@ impl OutlineLayout {
728836 return Err(LayoutError::InvalidSpacing);
729837 }
730838 }
839 siblings.push((node.level, number));
731840 block.state = state;
732841 if depth == 0 {
733842 result.blocks.push(block);
......@@ -738,6 +847,14 @@ impl OutlineLayout {
738847 Ok(result)
739848 }
740849
850 /// Root node `root`'s number, when it is numbered text on show.
851 fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<(u32, bool)> {
852 let block = &self.blocks[root];
853 (block.metrics.is_some() && nodes[root].text().is_some())
854 .then(|| self.paragraphs[block.first[0]].number)
855 .flatten()
856 }
857
741858 /// The paragraphs, tables and objects of root nodes `range`.
742859 pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] {
743860 let first = |root: usize| {
......@@ -752,7 +869,7 @@ impl OutlineLayout {
752869
753870 /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table
754871 /// holding its cell, and works out where the nodes after them move. Resizing columns or
755 /// showing or hiding other nodes lays out every node.
872 /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered.
756873 #[allow(clippy::too_many_arguments)]
757874 pub(crate) fn relayout(
758875 &self,
......@@ -762,7 +879,12 @@ impl OutlineLayout {
762879 indents: &[f32],
763880 width: f32,
764881 fixed_width: bool,
765 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
882 shape: &mut impl FnMut(
883 &PageParagraph,
884 Option<u32>,
885 f32,
886 &[f32],
887 ) -> Result<ParagraphLayout, LayoutError>,
766888 ) -> Result<Relayout, LayoutError> {
767889 let mut range = if edit.columns.is_empty() {
768890 range
......@@ -804,6 +926,19 @@ impl OutlineLayout {
804926 .start
805927 .checked_sub(1)
806928 .map_or((0.0, None), |root| self.blocks[root].state);
929 // Each earlier level's latest paragraph, back to the nearest at the first level.
930 let mut siblings = Vec::new();
931 let mut level = u32::MAX;
932 for root in (0..range.start).rev() {
933 if nodes[root].level < level {
934 level = nodes[root].level;
935 siblings.push((level, self.number(nodes, root).map(|(number, _)| number)));
936 if level <= 1 {
937 break;
938 }
939 }
940 }
941 siblings.reverse();
807942 let segment = Self::stack(
808943 replacement.clone(),
809944 indents,
......@@ -812,10 +947,12 @@ impl OutlineLayout {
812947 Some(edit),
813948 hiding,
814949 start,
815 &mut |node, width, indents| {
950 &mut siblings,
951 &mut |node, previous, width, indents| {
816952 let indent = indentation(node.level, indents, width)?;
817953 if let Some((source, cached)) = cached.get(&node.id)
818954 && *source == node
955 && follows(cached.number, previous) == cached.number.map(|(n, _)| n)
819956 && cached.text.shaped.layout_max_advance() == width - indent
820957 && cached
821958 .markers
......@@ -826,7 +963,7 @@ impl OutlineLayout {
826963 result.reset_origin(indent);
827964 return Ok(result);
828965 }
829 shape(node, width, indents)
966 shape(node, previous, width, indents)
830967 },
831968 )?;
832969 let hides =
......@@ -848,6 +985,23 @@ impl OutlineLayout {
848985 continue;
849986 }
850987 }
988 let mut renumbered = range.end;
989 for root in range.end..nodes.len() {
990 let previous = sibling(&mut siblings, nodes[root].level);
991 let number = self.number(nodes, root);
992 let now = follows(number, previous);
993 if now != number.map(|(number, _)| number) {
994 renumbered = root + 1;
995 }
996 siblings.push((nodes[root].level, now));
997 if nodes[root].level <= 1 && renumbered <= root {
998 break;
999 }
1000 }
1001 if renumbered > range.end {
1002 range.end = renumbered;
1003 continue;
1004 }
8511005 let mut state = segment.blocks.last().map_or(start, |block| block.state);
8521006 let mut incoming = range
8531007 .end
......@@ -1002,7 +1156,12 @@ impl OutlineLayout {
10021156 table: &Table,
10031157 depth: usize,
10041158 edit: Option<&DocumentEdit>,
1005 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
1159 shape: &mut impl FnMut(
1160 &PageParagraph,
1161 Option<u32>,
1162 f32,
1163 &[f32],
1164 ) -> Result<ParagraphLayout, LayoutError>,
10061165 ) -> Result<Self, LayoutError> {
10071166 let widths = edit.and_then(|edit| edit.columns.get(&table.id));
10081167 if widths.is_some_and(|widths| widths.len() != table.columns.len()) {
......@@ -1135,8 +1294,8 @@ pub(crate) fn outline_layout(
11351294 outline.layout.width_set_by_user == Some(true),
11361295 0,
11371296 None,
1138 &mut |node, width, indents| {
1139 ParagraphLayout::shape(engine, node, width, indents, definitions)
1297 &mut |node, previous, width, indents| {
1298 ParagraphLayout::shape(engine, node, previous, width, indents, definitions)
11401299 },
11411300 )
11421301}
......@@ -1373,8 +1532,15 @@ mod tests {
13731532 false,
13741533 0,
13751534 None,
1376 &mut |node, width, indents| {
1377 ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new())
1535 &mut |node, previous, width, indents| {
1536 ParagraphLayout::shape(
1537 &mut engine,
1538 node,
1539 previous,
1540 width,
1541 indents,
1542 &BTreeMap::new(),
1543 )
13781544 },
13791545 )
13801546 .unwrap();
......@@ -1490,9 +1656,15 @@ mod tests {
14901656 let mut node = paragraph(1, "Text that wraps across several lines", 2, None);
14911657 let mut engine = TextEngine::default();
14921658 let mut definitions = BTreeMap::new();
1493 let plain =
1494 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions)
1495 .unwrap();
1659 let plain = ParagraphLayout::shape(
1660 &mut engine,
1661 &node,
1662 None,
1663 120.0,
1664 &[18.0, 0.0, 27.0],
1665 &definitions,
1666 )
1667 .unwrap();
14961668 node.text_mut().unwrap().tags.push(Tag {
14971669 definition: Some(id),
14981670 status: 3,
......@@ -1526,9 +1698,15 @@ mod tests {
15261698 format: Format::default(),
15271699 },
15281700 );
1529 let tagged =
1530 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions)
1531 .unwrap();
1701 let tagged = ParagraphLayout::shape(
1702 &mut engine,
1703 &node,
1704 None,
1705 120.0,
1706 &[18.0, 0.0, 27.0],
1707 &definitions,
1708 )
1709 .unwrap();
15321710 assert_eq!(tagged.tags[0].icon, expected);
15331711 assert_eq!(tagged.tags[0].origin, [6.75, 0.0]);
15341712 assert_eq!(tagged.tags[0].label, "Label");
......@@ -1564,9 +1742,15 @@ mod tests {
15641742 },
15651743 );
15661744 node.lists.push(list);
1567 let tagged =
1568 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions)
1569 .unwrap();
1745 let tagged = ParagraphLayout::shape(
1746 &mut engine,
1747 &node,
1748 None,
1749 120.0,
1750 &[18.0, 0.0, 27.0],
1751 &definitions,
1752 )
1753 .unwrap();
15701754 assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25);
15711755 assert_eq!(tagged.tags[0].origin[1], 0.0);
15721756 let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else {
......@@ -1574,7 +1758,14 @@ mod tests {
15741758 };
15751759 *shape = Some(999);
15761760 assert!(matches!(
1577 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions),
1761 ParagraphLayout::shape(
1762 &mut engine,
1763 &node,
1764 None,
1765 120.0,
1766 &[18.0, 0.0, 27.0],
1767 &definitions
1768 ),
15781769 Err(LayoutError::UnsupportedContent)
15791770 ));
15801771 }
......@@ -1623,8 +1814,15 @@ mod tests {
16231814 false,
16241815 0,
16251816 None,
1626 &mut |node, width, indents| {
1627 ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new())
1817 &mut |node, previous, width, indents| {
1818 ParagraphLayout::shape(
1819 &mut engine,
1820 node,
1821 previous,
1822 width,
1823 indents,
1824 &BTreeMap::new(),
1825 )
16281826 },
16291827 )
16301828 .unwrap();
......@@ -1688,7 +1886,8 @@ mod tests {
16881886 node.text_mut().unwrap().tags = vec![question, project, mood, action];
16891887 let mut engine = TextEngine::default();
16901888 let shaped =
1691 ParagraphLayout::shape(&mut engine, &node, 200.0, &[0.0, 0.0], &definitions).unwrap();
1889 ParagraphLayout::shape(&mut engine, &node, None, 200.0, &[0.0, 0.0], &definitions)
1890 .unwrap();
16921891 assert_eq!(
16931892 shaped
16941893 .tags
......@@ -1914,4 +2113,16 @@ mod tests {
19142113 );
19152114 assert_eq!(layout.size[0], 110.0);
19162115 }
2116
2117 #[test]
2118 fn numerals_follow_onenote_number_sequences() {
2119 assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12");
2120 assert_eq!(numeral(Some('\u{1}'), 1994).unwrap(), "MCMXCIV");
2121 assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv");
2122 assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C");
2123 assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z");
2124 assert!(numeral(Some('\u{4}'), 27).is_err());
2125 assert!(numeral(Some('\u{1}'), 0).is_err());
2126 assert!(numeral(Some('\u{9}'), 1).is_err());
2127 }
19172128}