1use super::*;
2use onestore::page::text::new_id;
3use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
4
5struct CellLocation<'a> {
6 container: Option<ExGuid>,
7 paragraph: usize,
8 node: &'a PageParagraph,
9 row: usize,
10 column: usize,
11}
12
13fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {
14 let mut pending = vec![(None, document.nodes())];
15 while let Some((container, nodes)) = pending.pop() {
16 for (paragraph, node) in nodes.iter().enumerate() {
17 if let ParagraphContent::Table(table) = &node.content {
18 for (row, source) in table.rows.iter().enumerate() {
19 for (column, cell) in source.cells.iter().enumerate() {
20 if cell.id == id {
21 return Some(CellLocation {
22 container,
23 paragraph,
24 node,
25 row,
26 column,
27 });
28 }
29 pending.push((Some(cell.id), cell.paragraphs.as_slice()));
30 }
31 }
32 }
33 }
34 }
35 None
36}
37
38/// Table `id`'s paragraph, its container and its index there.
39fn table_node(
40 document: &TextDocument,
41 id: ExGuid,
42) -> Result<(Option<ExGuid>, usize, &PageParagraph), EditError> {
43 descendants(document.nodes(), None)
44 .find(|(_, _, node)| {
45 matches!(&node.content, ParagraphContent::Table(table) if table.id == id)
46 })
47 .ok_or(EditError::InvalidRange)
48}
49
50/// A new cell like `source`, one empty paragraph in its first text's format; a cell holding
51/// no text, only pictures or files, gives a plain one.
52fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
53 let node = match leaves(&source.paragraphs, None).next() {
54 Some((_, _, paragraph)) => {
55 let format = paragraph.text().unwrap().text.format_at(0)?;
56 let mut node = crate::document::node(
57 Paragraph::new(String::new(), format.clone()),
58 paragraph.format.clone(),
59 )?;
60 node.style = paragraph.style;
61 node
62 }
63 None => crate::document::node(
64 Paragraph::new(String::new(), Default::default()),
65 Default::default(),
66 )?,
67 };
68 new_cell(source.indents.clone(), source.shading, vec![node])
69}
70
71/// A new cell of `paragraphs`.
72fn new_cell(
73 indents: Vec<f32>,
74 shading: Option<u32>,
75 paragraphs: Vec<PageParagraph>,
76) -> Result<TableCell, EditError> {
77 Ok(TableCell {
78 id: new_id()?,
79 layout: Default::default(),
80 indents,
81 shading,
82 paragraphs,
83 unsupported: Vec::new(),
84 })
85}
86
87/// A new bordered table of `rows` and `columns` new columns, in `source`'s place in the
88/// outline's tree.
89fn table_paragraph(
90 source: &PageParagraph,
91 columns: usize,
92 rows: Vec<TableRow>,
93) -> Result<PageParagraph, EditError> {
94 Ok(PageParagraph {
95 id: new_id()?,
96 parent: source.parent,
97 level: source.level,
98 style: None,
99 format: source.format.clone(),
100 lists: Vec::new(),
101 tags: Vec::new(),
102 media: Default::default(),
103 collapsed: false,
104 content: ParagraphContent::Table(Table {
105 id: new_id()?,
106 columns: vec![
107 TableColumn {
108 width: COLUMN_WIDTH,
109 locked: false
110 };
111 columns
112 ],
113 rows,
114 borders: Some(true),
115 layout: Default::default(),
116 tags: Vec::new(),
117 }),
118 })
119}
120
121fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> {
122 let mut content = leaves(&cell.paragraphs, None);
123 let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?;
124 let (_, _, last) = content.last().unwrap_or((None, 0, first));
125 let start = document
126 .text_nodes()
127 .position(|node| node.id == first.id)
128 .ok_or(EditError::InvalidStructure)?;
129 let end = document
130 .text_nodes()
131 .position(|node| node.id == last.id)
132 .ok_or(EditError::InvalidStructure)?;
133 let text = &last.text().unwrap().text;
134 Ok(TextPosition {
135 paragraph: start,
136 offset: 0,
137 }..TextPosition {
138 paragraph: end,
139 offset: text.utf16_offset(text.text().len())?,
140 })
141}
142
143/// OneNote 2010's width for a new column, and the narrowest it fits or drags one to.
144pub(super) const COLUMN_WIDTH: f32 = 37.11;
145/// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30).
146const COLUMN_ROOM: f32 = 4.347;
147
148/// `width` as it reads back from the file, which stores it in half inches.
149fn stored(width: f32) -> f32 {
150 width / 36.0 * 36.0
151}
152
153/// `table`'s widths with its unlocked columns in `only`, or all, fit to their widest cell
154/// as `natural` measures it, the table no wider than `room` unless its narrowest columns are.
155fn fitted(
156 table: &Table,
157 only: Option<usize>,
158 room: f32,
159 mut natural: impl FnMut(&TableCell) -> Result<f32, LayoutError>,
160) -> Result<Vec<f32>, LayoutError> {
161 let mut widths = table
162 .columns
163 .iter()
164 .map(|column| column.width)
165 .collect::<Vec<_>>();
166 for (index, column) in table.columns.iter().enumerate() {
167 if column.locked || only.is_some_and(|only| only != index) {
168 continue;
169 }
170 let mut widest = 0.0_f32;
171 for row in &table.rows {
172 widest = widest.max(natural(
173 row.cells.get(index).ok_or(LayoutError::InvalidWidth)?,
174 )?);
175 }
176 // Columns sit 4.98 pt apart, and the table ends 3.15 pt past the last one.
177 let others = widths.iter().map(|width| width + 4.98).sum::<f32>() - widths[index] - 1.83;
178 widths[index] = stored((widest + COLUMN_ROOM).min(room - others).max(COLUMN_WIDTH));
179 }
180 Ok(widths)
181}
182
183/// Fits the tables in `nodes`, innermost first, placed by `indents` in an outline `wrap` wide.
184fn fit_tables(
185 nodes: &mut [PageParagraph],
186 indents: &[f32],
187 wrap: f32,
188 natural: &mut impl FnMut(&TableCell) -> Result<f32, LayoutError>,
189) -> Result<(), LayoutError> {
190 for node in nodes {
191 let level = node.level;
192 if let ParagraphContent::Table(table) = &mut node.content {
193 for TableCell {
194 paragraphs,
195 indents,
196 ..
197 } in table.rows.iter_mut().flat_map(|row| &mut row.cells)
198 {
199 fit_tables(paragraphs, indents, wrap, natural)?;
200 }
201 let room = wrap - crate::outline::indentation(level, indents, wrap)?;
202 let widths = fitted(table, None, room, &mut *natural)?;
203 for (column, width) in table.columns.iter_mut().zip(widths) {
204 column.width = width;
205 }
206 }
207 }
208 Ok(())
209}
210
211impl CanvasEditor {
212 /// Fits the unlocked columns `edit` writes in to their content, as OneNote 2010 widens
213 /// and narrows them while typing: the tables it adds or rewrites, and the column of each
214 /// table around the cell it edits. A table stops at the outline's width and wraps there.
215 pub(super) fn fit_columns(
216 &self,
217 engine: &mut TextEngine,
218 edit: &mut DocumentEdit,
219 ) -> Result<(), EditorError> {
220 let outline = self.active_outline();
221 if outline.title {
222 return Ok(());
223 }
224 let wrap = outline.wrap_width();
225 let mut natural = |cell: &TableCell, edit: Option<&DocumentEdit>| {
226 let flow = OutlineLayout::flow(
227 crate::document::edited_nodes(&cell.paragraphs, Some(cell.id), edit),
228 &cell.indents,
229 f32::from(u16::MAX),
230 false,
231 1,
232 edit,
233 &mut |node, previous, width, indents| {
234 ParagraphLayout::shape(
235 engine,
236 node,
237 previous,
238 width,
239 indents,
240 &self.definitions,
241 )
242 },
243 )?;
244 Ok::<_, LayoutError>(flow.content_width())
245 };
246 let indents = |container: Option<ExGuid>| match container {
247 Some(cell) => {
248 let location =
249 locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
250 let ParagraphContent::Table(table) = &location.node.content else {
251 unreachable!()
252 };
253 Ok::<_, EditError>(&table.rows[location.row].cells[location.column].indents)
254 }
255 None => Ok(&outline.indents),
256 };
257 fit_tables(
258 &mut edit.replacement,
259 indents(edit.container)?,
260 wrap,
261 &mut |cell| natural(cell, None),
262 )?;
263 let mut container = edit.container;
264 while let Some(cell) = container {
265 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
266 let ParagraphContent::Table(table) = &location.node.content else {
267 unreachable!()
268 };
269 if !edit.columns.contains_key(&table.id) {
270 let room = wrap
271 - crate::outline::indentation(
272 location.node.level,
273 indents(location.container)?,
274 wrap,
275 )?;
276 let widths = fitted(table, Some(location.column), room, |cell| {
277 natural(cell, Some(edit))
278 })?;
279 if widths
280 .iter()
281 .zip(&table.columns)
282 .any(|(width, column)| *width != column.width)
283 {
284 edit.columns.insert(table.id, widths);
285 }
286 }
287 container = location.container;
288 }
289 Ok(())
290 }
291}
292
293impl TextOutline {
294 /// The table column whose right border lies within `reach` of outline-local `point`, as
295 /// `(table, column, width)`.
296 pub(crate) fn column_border(
297 &self,
298 point: [f32; 2],
299 reach: f32,
300 ) -> Option<(ExGuid, usize, f32)> {
301 let columns = descendants(self.document.nodes(), None)
302 .filter_map(|(_, _, node)| match &node.content {
303 ParagraphContent::Table(table) => Some((table.id, table.columns.len())),
304 _ => None,
305 })
306 .collect::<BTreeMap<_, _>>();
307 // Nested tables follow the tables holding them.
308 self.shaped.tables.iter().rev().find_map(|table| {
309 let (first, last) = (table.cells.first()?, table.cells.last()?);
310 if !(first.rect[1]..=last.rect[3]).contains(&point[1]) {
311 return None;
312 }
313 let row = table.cells.get(..*columns.get(&table.id)?)?;
314 row.iter().enumerate().find_map(|(column, cell)| {
315 // A cell's box reaches 3.6 pt before its column and 1.38 pt past it.
316 ((point[0] - cell.rect[2]).abs() <= reach)
317 .then(|| (table.id, column, cell.rect[2] - cell.rect[0] - 4.98))
318 })
319 })
320 }
321}
322
323impl CanvasEditor {
324 /// The active outline with column `column` of `table` `width` wide, as a border drag
325 /// shows it before release.
326 pub(crate) fn preview_column(
327 &self,
328 engine: &mut TextEngine,
329 table: ExGuid,
330 column: usize,
331 width: f32,
332 ) -> Result<TextOutline, EditorError> {
333 let outline = self.active_outline();
334 let (_, _, node) = table_node(&outline.document, table)?;
335 let ParagraphContent::Table(source) = &node.content else {
336 unreachable!()
337 };
338 let mut widths = source
339 .columns
340 .iter()
341 .map(|column| column.width)
342 .collect::<Vec<_>>();
343 *widths.get_mut(column).ok_or(EditError::InvalidRange)? = width.max(COLUMN_WIDTH);
344 let edit = DocumentEdit {
345 container: None,
346 range: 0..0,
347 replacement: Vec::new(),
348 columns: [(table, widths)].into(),
349 };
350 let shaped = OutlineLayout::flow(
351 outline.document.nodes().iter(),
352 &outline.indents,
353 outline.wrap_width(),
354 outline.layout.width_set_by_user == Some(true),
355 0,
356 Some(&edit),
357 &mut |node, previous, width, indents| {
358 ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions)
359 },
360 )?;
361 let mut preview = outline.clone();
362 preview.document.apply(edit)?;
363 preview.shaped = shaped;
364 Ok(preview)
365 }
366
367 /// Drags a border to make column `column` of `table` `width` wide, as OneNote 2010 does:
368 /// no narrower than a new column, locked against fitting, the columns after it moving
369 /// with it. One edit and one undo step.
370 pub fn resize_column(
371 &mut self,
372 engine: &mut TextEngine,
373 table: ExGuid,
374 column: usize,
375 width: f32,
376 ) -> Result<(), EditorError> {
377 if !width.is_finite() {
378 return Err(LayoutError::InvalidWidth.into());
379 }
380 let outline = self.active_outline();
381 let (container, index, node) = table_node(&outline.document, table)?;
382 let mut node = node.clone();
383 let ParagraphContent::Table(source) = &mut node.content else {
384 unreachable!()
385 };
386 let resized = TableColumn {
387 width: stored(width.max(COLUMN_WIDTH)),
388 locked: true,
389 };
390 let slot = source
391 .columns
392 .get_mut(column)
393 .ok_or(EditError::InvalidRange)?;
394 if *slot == resized {
395 return Ok(());
396 }
397 *slot = resized;
398 let selection = outline.selection;
399 self.finish_composition();
400 self.commit(
401 engine,
402 DocumentEdit {
403 columns: BTreeMap::new(),
404 container,
405 range: index..index + 1,
406 replacement: vec![node],
407 },
408 selection,
409 )
410 }
411 /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in
412 /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before
413 /// or after the caret's paragraph at its start or end, and between its halves otherwise.
414 pub fn insert_table(
415 &mut self,
416 engine: &mut TextEngine,
417 rows: usize,
418 columns: usize,
419 ) -> Result<(), EditorError> {
420 let outline = self.active_outline();
421 if outline.title || rows == 0 || columns == 0 {
422 return Ok(());
423 }
424 let [anchor, focus] = outline.selection.positions;
425 let caret = anchor.max(focus);
426 let (container, index, node) = outline
427 .document
428 .leaf(caret.paragraph)
429 .ok_or(EditError::InvalidRange)?;
430 let text = &node.text().unwrap().text;
431 let end = text.utf16_offset(text.text().len())?;
432 let format = text.format_at(caret.offset)?;
433 let empty = || -> Result<TableCell, EditError> {
434 let mut paragraph = crate::document::node(
435 Paragraph::new(String::new(), format.clone()),
436 node.format.clone(),
437 )?;
438 paragraph.style = node.style;
439 new_cell(outline.indents.clone(), None, vec![paragraph])
440 };
441 let rows = (0..rows)
442 .map(|_| {
443 Ok(TableRow {
444 id: new_id()?,
445 cells: (0..columns).map(|_| empty()).collect::<Result<_, _>>()?,
446 })
447 })
448 .collect::<Result<_, EditError>>()?;
449 let table = table_paragraph(node, columns, rows)?;
450 let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id);
451 let childless = !outline.document.container(container)?.iter().any(parent);
452 let (edit, first) = if end == 0 && childless {
453 let edit = DocumentEdit {
454 columns: BTreeMap::new(),
455 container,
456 range: index..index + 1,
457 replacement: vec![table],
458 };
459 (edit, caret.paragraph)
460 } else if caret.offset == 0 && end > 0 {
461 let edit = DocumentEdit {
462 columns: BTreeMap::new(),
463 container,
464 range: index..index,
465 replacement: vec![table],
466 };
467 (edit, caret.paragraph)
468 } else if caret.offset == end {
469 let edit = DocumentEdit {
470 columns: BTreeMap::new(),
471 container,
472 range: index + 1..index + 1,
473 replacement: vec![table],
474 };
475 (edit, caret.paragraph + 1)
476 } else {
477 let empty = Paragraph::new(String::new(), format.clone());
478 let mut edit = outline
479 .document
480 .replace(caret..caret, vec![empty.clone(), empty])?;
481 edit.replacement.insert(1, table);
482 (edit, caret.paragraph + 1)
483 };
484 let first = TextPosition {
485 paragraph: first,
486 offset: 0,
487 };
488 self.commit(engine, edit, [first; 2].into())
489 }
490
491 pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> {
492 if self.active_outline().title && !backward {
493 return self.leave_title(engine);
494 }
495 let outline = self.active_outline();
496 let [anchor, focus] = outline.selection.positions;
497 let (cell, local, source) = outline
498 .document
499 .leaf(focus.paragraph)
500 .ok_or(EditError::InvalidRange)?;
501 let Some(cell) = cell else {
502 if backward || anchor != focus || focus.offset == 0 {
503 self.indent(engine, backward)?;
504 return Ok(());
505 }
506 let format = source.text().unwrap().text.format_at(focus.offset)?;
507 let mut split = outline.document.replace(
508 focus..focus,
509 vec![Paragraph::new(String::new(), format.clone()); 2],
510 )?;
511 // The table's paragraph holds the list, tags and children (`evidence/structural-edits/
512 // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`).
513 let children = split.replacement.split_off(2);
514 let head = &mut split.replacement[0];
515 let lists = std::mem::take(&mut head.lists);
516 let mut tags = std::mem::take(&mut head.tags);
517 tags.append(&mut head.text_mut().unwrap().tags);
518 let collapsed = std::mem::take(&mut split.replacement[1].collapsed);
519 let tail = split.replacement[1].id;
520 let cells = split
521 .replacement
522 .drain(..)
523 .map(|mut paragraph| {
524 paragraph.level = 1;
525 paragraph.parent = None;
526 paragraph.lists.clear();
527 new_cell(outline.indents.clone(), None, vec![paragraph])
528 })
529 .collect::<Result<Vec<_>, EditError>>()?;
530 let row = TableRow {
531 id: new_id()?,
532 cells,
533 };
534 let wrapper = PageParagraph {
535 lists,
536 tags,
537 collapsed,
538 ..table_paragraph(source, 2, vec![row])?
539 };
540 let id = wrapper.id;
541 split.replacement.push(wrapper);
542 split
543 .replacement
544 .extend(children.into_iter().map(|mut child| {
545 if child.parent == Some(tail) {
546 child.parent = Some(id);
547 }
548 child
549 }));
550 return self.commit(
551 engine,
552 split,
553 [TextPosition {
554 paragraph: focus.paragraph + 1,
555 offset: 0,
556 }; 2]
557 .into(),
558 );
559 };
560 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
561 let ParagraphContent::Table(table) = &location.node.content else {
562 unreachable!()
563 };
564 let slot = location.row * table.columns.len() + location.column;
565 let next = if backward {
566 slot.checked_sub(1)
567 } else if slot + 1 < table.rows.len() * table.columns.len() {
568 Some(slot + 1)
569 } else {
570 None
571 };
572 if let Some(next) = next {
573 let target = &table.rows[next / table.columns.len()].cells[next % table.columns.len()];
574 let range = cell_range(&outline.document, target)?;
575 return self
576 .select([range.start, range.end].into())
577 .map_err(Into::into);
578 }
579 if backward {
580 return Ok(());
581 }
582 let mut wrapper = location.node.clone();
583 let ParagraphContent::Table(table) = &mut wrapper.content else {
584 unreachable!()
585 };
586 let next = if table.rows.len() == 1 {
587 let format = source.text().unwrap().text.format_at(focus.offset)?;
588 let split = outline.document.replace(
589 focus..focus,
590 vec![Paragraph::new(String::new(), format.clone()); 2],
591 )?;
592 let source = &mut table.rows[0].cells[location.column];
593 source.paragraphs.splice(split.range, split.replacement);
594 let paragraphs = source.paragraphs.split_off(local + 1);
595 let target = new_cell(source.indents.clone(), source.shading, paragraphs)?;
596 table.rows[0].cells.push(target);
597 table.columns.push(TableColumn {
598 width: COLUMN_WIDTH,
599 locked: false,
600 });
601 TextPosition {
602 paragraph: focus.paragraph + 1,
603 offset: 0,
604 }
605 } else {
606 let cells = table
607 .rows
608 .last()
609 .unwrap()
610 .cells
611 .iter()
612 .map(empty_cell)
613 .collect::<Result<Vec<_>, _>>()?;
614 table.rows.push(TableRow {
615 id: new_id()?,
616 cells,
617 });
618 let paragraph = cell_range(
619 &outline.document,
620 &table.rows[location.row].cells[location.column],
621 )?
622 .end
623 .paragraph
624 + 1;
625 TextPosition {
626 paragraph,
627 offset: 0,
628 }
629 };
630 self.commit(
631 engine,
632 DocumentEdit {
633 columns: BTreeMap::new(),
634 container: location.container,
635 range: location.paragraph..location.paragraph + 1,
636 replacement: vec![wrapper],
637 },
638 [next; 2].into(),
639 )
640 }
641 pub fn enter(&mut self, engine: &mut TextEngine, soft: bool) -> Result<(), EditorError> {
642 let [anchor, focus] = self.active_outline().selection.positions;
643 // Inside a link's label OneNote 2010 ignores Shift+Enter, and Enter follows the link
644 // (`PageView` asks the host), so neither breaks it.
645 if anchor == focus
646 && self.link_at(focus).is_some_and(|link| {
647 link.label.start < focus.offset && focus.offset < link.label.end
648 })
649 {
650 return Ok(());
651 }
652 if soft {
653 return self.insert(engine, "\u{000b}");
654 }
655 self.take_objects()?;
656 if self.active_outline().title {
657 return self.leave_title(engine);
658 }
659 let [anchor, focus] = self.active_outline().selection.positions;
660 if anchor == focus {
661 self.link_typed_url(engine, focus)?;
662 // Enter at a label's start splits before its field code.
663 self.leave_link_code()?;
664 if self.break_equation(engine)? {
665 return Ok(());
666 }
667 }
668 let [anchor, focus] = self.active_outline().selection.positions;
669 let outline = self.active_outline();
670 let (cell, local, source) = outline
671 .document
672 .leaf(focus.paragraph)
673 .ok_or(EditError::InvalidRange)?;
674 let Some(cell) = cell.filter(|_| anchor == focus) else {
675 return self.split(engine);
676 };
677 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
678 let ParagraphContent::Table(table) = &location.node.content else {
679 unreachable!()
680 };
681 let row = &table.rows[location.row];
682 let exit = location.row + 1 == table.rows.len()
683 && location.row > 0
684 && location.column == 0
685 && local == 0
686 && focus.offset == 0
687 && row.cells.iter().all(|cell| {
688 cell.paragraphs
689 .iter()
690 .all(|node| node.text().is_some_and(|text| text.text.text().is_empty()))
691 });
692 let text = &source.text().unwrap().text;
693 let append = location.row + 1 == table.rows.len()
694 && location.column + 1 == table.columns.len()
695 && local + 1 == row.cells[location.column].paragraphs.len()
696 && focus.offset == text.utf16_offset(text.text().len())?;
697 if !exit && !append {
698 return self.split(engine);
699 }
700 let mut wrapper = location.node.clone();
701 let ParagraphContent::Table(table) = &mut wrapper.content else {
702 unreachable!()
703 };
704 let replacement = if exit {
705 table.rows.pop();
706 let blank = || {
707 Ok::<_, EditError>(PageParagraph {
708 parent: location.node.parent,
709 level: location.node.level,
710 format: location.node.format.clone(),
711 ..self.blank_paragraph(source)?
712 })
713 };
714 vec![wrapper, blank()?, blank()?]
715 } else {
716 let cells = row
717 .cells
718 .iter()
719 .map(empty_cell)
720 .collect::<Result<Vec<_>, _>>()?;
721 table.rows.push(TableRow {
722 id: new_id()?,
723 cells,
724 });
725 vec![wrapper]
726 };
727 let next = TextPosition {
728 paragraph: focus.paragraph + 1,
729 offset: 0,
730 };
731 self.commit(
732 engine,
733 DocumentEdit {
734 columns: BTreeMap::new(),
735 container: location.container,
736 range: location.paragraph..location.paragraph + 1,
737 replacement,
738 },
739 [next; 2].into(),
740 )
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use onestore::document::Format;
748
749 fn editor(engine: &mut TextEngine, text: &str) -> CanvasEditor {
750 CanvasEditor::new(
751 engine,
752 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
753 180.0,
754 )
755 .unwrap()
756 }
757
758 fn cells(editor: &CanvasEditor) -> Vec<Vec<String>> {
759 let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content
760 else {
761 panic!()
762 };
763 table
764 .rows
765 .iter()
766 .flat_map(|row| &row.cells)
767 .map(|cell| {
768 cell.paragraphs
769 .iter()
770 .map(|node| node.text().unwrap().text.text().to_owned())
771 .collect()
772 })
773 .collect()
774 }
775
776 /// As OneNote 2010 inserts from the Table gallery (lab, 2026-09-28).
777 #[test]
778 fn an_inserted_table_takes_an_empty_paragraph_or_splits_at_the_caret() {
779 let mut engine = TextEngine::default();
780 let texts = |editor: &CanvasEditor| {
781 editor
782 .active_outline()
783 .document
784 .nodes()
785 .iter()
786 .map(|node| match &node.content {
787 ParagraphContent::Table(table) => {
788 format!("{}x{}", table.columns.len(), table.rows.len())
789 }
790 _ => node.text().unwrap().text.text().to_owned(),
791 })
792 .collect::<Vec<_>>()
793 };
794 for (text, offset, expected) in [
795 ("", 0, vec!["3x2"]),
796 ("abc", 0, vec!["3x2", "abc"]),
797 ("abc", 3, vec!["abc", "3x2"]),
798 ("abc", 1, vec!["a", "3x2", "bc"]),
799 ] {
800 let mut editor = editor(&mut engine, text);
801 let caret = TextPosition {
802 paragraph: 0,
803 offset,
804 };
805 editor.select([caret; 2].into()).unwrap();
806 editor.insert_table(&mut engine, 2, 3).unwrap();
807 assert_eq!(texts(&editor), expected, "{text:?} at {offset}");
808 let first = usize::from(expected[0] != "3x2");
809 let (cell, _, _) = editor
810 .active_outline()
811 .document
812 .leaf(editor.selection().positions[0].paragraph)
813 .unwrap();
814 assert!(cell.is_some() && editor.selection().positions[0].paragraph == first);
815 }
816 }
817
818 #[test]
819 fn tab_creates_splits_and_selects_native_cells() {
820 let mut engine = TextEngine::default();
821 let mut editor = editor(&mut engine, "abcdef");
822 let original = editor.active_outline().document.clone();
823 editor
824 .select(
825 [TextPosition {
826 paragraph: 0,
827 offset: 3,
828 }; 2]
829 .into(),
830 )
831 .unwrap();
832 editor.tab(&mut engine, false).unwrap();
833 assert_eq!(cells(&editor), [vec!["abc"], vec!["def"]]);
834 assert_eq!(
835 editor
836 .active_outline()
837 .document
838 .text_nodes()
839 .next()
840 .unwrap()
841 .id,
842 original.nodes()[0].id
843 );
844 assert_eq!(
845 editor.selection().positions,
846 [TextPosition {
847 paragraph: 1,
848 offset: 0
849 }; 2]
850 );
851 editor.insert(&mut engine, "X").unwrap();
852 editor.tab(&mut engine, false).unwrap();
853 assert_eq!(cells(&editor), [vec!["abc"], vec!["X"], vec!["def"]]);
854 editor.tab(&mut engine, true).unwrap();
855 assert_eq!(
856 editor.selection().positions,
857 [
858 TextPosition {
859 paragraph: 1,
860 offset: 0
861 },
862 TextPosition {
863 paragraph: 1,
864 offset: 1
865 }
866 ]
867 );
868 editor.insert(&mut engine, "REPLACED").unwrap();
869 assert_eq!(cells(&editor)[1], ["REPLACED"]);
870 editor.tab(&mut engine, true).unwrap();
871 assert_eq!(
872 editor.selection().positions,
873 [
874 TextPosition {
875 paragraph: 0,
876 offset: 0
877 },
878 TextPosition {
879 paragraph: 0,
880 offset: 3
881 }
882 ]
883 );
884 editor
885 .select(
886 [TextPosition {
887 paragraph: 0,
888 offset: 2,
889 }; 2]
890 .into(),
891 )
892 .unwrap();
893 editor.tab(&mut engine, true).unwrap();
894 editor.insert(&mut engine, "!").unwrap();
895 assert_eq!(cells(&editor)[0], ["ab!c"]);
896 editor.tab(&mut engine, false).unwrap();
897 assert_eq!(
898 editor.selection().positions[1],
899 TextPosition {
900 paragraph: 1,
901 offset: 8
902 }
903 );
904 assert_eq!(editor.active_outline().layout.max_width, Some(180.0));
905 for _ in 0..5 {
906 assert!(editor.undo(&mut engine).unwrap());
907 }
908 assert_eq!(editor.active_outline().document, original);
909 for _ in 0..5 {
910 assert!(editor.redo(&mut engine).unwrap());
911 }
912 assert_eq!(
913 cells(&editor),
914 [vec!["ab!c"], vec!["REPLACED"], vec!["def"]]
915 );
916 }
917
918 #[test]
919 fn adding_rows_preserves_nested_cells_and_undo() {
920 let mut engine = TextEngine::default();
921 let mut outer = editor(&mut engine, "");
922 outer.insert(&mut engine, "Alpha").unwrap();
923 outer.tab(&mut engine, false).unwrap();
924 outer.insert(&mut engine, "Beta").unwrap();
925 outer.enter(&mut engine, false).unwrap();
926 outer.insert(&mut engine, "Gamma").unwrap();
927 outer.tab(&mut engine, false).unwrap();
928 outer.insert(&mut engine, "Delta").unwrap();
929 let mut inner = editor(&mut engine, "");
930 inner.insert(&mut engine, "Nested").unwrap();
931 inner.tab(&mut engine, false).unwrap();
932 let mut source = outer.active_outline().snapshot();
933 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
934 panic!()
935 };
936 table.rows[1].cells[0].paragraphs = inner.active_outline().document.nodes().to_vec();
937 for tab in [false, true] {
938 let mut editor =
939 CanvasEditor::from_outlines(&mut engine, vec![source.clone()], BTreeMap::new())
940 .unwrap();
941 let original = editor.active_outline().document.clone();
942 let end = TextPosition {
943 paragraph: original.text_nodes().count() - 1,
944 offset: 5,
945 };
946 editor.select([end; 2].into()).unwrap();
947 if tab {
948 editor.tab(&mut engine, false).unwrap();
949 } else {
950 editor.enter(&mut engine, false).unwrap();
951 }
952 let added = editor.active_outline().document.clone();
953 let ParagraphContent::Table(before) = &original.nodes()[0].content else {
954 panic!()
955 };
956 let ParagraphContent::Table(after) = &added.nodes()[0].content else {
957 panic!()
958 };
959 assert_eq!(after.rows.len(), 3);
960 assert_eq!(&after.rows[..2], &before.rows);
961 // The first column widens to the table placed in it unfitted.
962 assert!(after.columns[0].width > before.columns[0].width + 40.0);
963 assert_eq!(after.columns[1], before.columns[1]);
964 for cell in &after.rows[2].cells {
965 assert_eq!(cell.paragraphs.len(), 1);
966 assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty());
967 }
968 assert_eq!(editor.selection().positions[1].paragraph, end.paragraph + 1);
969 editor.insert(&mut engine, "New row").unwrap();
970 assert_eq!(
971 editor
972 .active_outline()
973 .document
974 .paragraphs()
975 .nth(end.paragraph + 1)
976 .unwrap()
977 .text(),
978 "New row"
979 );
980 assert!(editor.undo(&mut engine).unwrap());
981 assert_eq!(editor.active_outline().document, added);
982 assert!(editor.undo(&mut engine).unwrap());
983 assert_eq!(editor.active_outline().document, original);
984 assert_eq!(editor.selection().positions, [end; 2]);
985 assert!(editor.redo(&mut engine).unwrap());
986 assert_eq!(editor.active_outline().document, added);
987 }
988 }
989
990 #[test]
991 fn native_rows_exit_and_text_have_separate_undo_steps() {
992 let mut engine = TextEngine::default();
993 let mut editor = editor(&mut engine, "Alpha");
994 editor
995 .select(
996 [TextPosition {
997 paragraph: 0,
998 offset: 5,
999 }; 2]
1000 .into(),
1001 )
1002 .unwrap();
1003 editor.tab(&mut engine, false).unwrap();
1004 editor.insert(&mut engine, "Beta").unwrap();
1005 editor.enter(&mut engine, false).unwrap();
1006 assert_eq!(
1007 editor.selection().positions,
1008 [TextPosition {
1009 paragraph: 2,
1010 offset: 0
1011 }; 2]
1012 );
1013 editor.insert(&mut engine, "Gamma").unwrap();
1014 editor.tab(&mut engine, false).unwrap();
1015 editor.insert(&mut engine, "Delta").unwrap();
1016 let two_rows = editor.active_outline().document.clone();
1017 editor.tab(&mut engine, false).unwrap();
1018 editor.insert(&mut engine, "Epsilon").unwrap();
1019 assert_eq!(
1020 cells(&editor),
1021 [
1022 vec!["Alpha"],
1023 vec!["Beta"],
1024 vec!["Gamma"],
1025 vec!["Delta"],
1026 vec!["Epsilon"],
1027 vec![""]
1028 ]
1029 );
1030 editor.undo(&mut engine).unwrap();
1031 assert_eq!(cells(&editor)[4], [""]);
1032 editor.undo(&mut engine).unwrap();
1033 assert_eq!(editor.active_outline().document, two_rows);
1034 editor.redo(&mut engine).unwrap();
1035 editor.enter(&mut engine, false).unwrap();
1036 assert_eq!(cells(&editor).len(), 4);
1037 assert_eq!(editor.active_outline().document.nodes().len(), 3);
1038 assert_eq!(
1039 editor.selection().positions,
1040 [TextPosition {
1041 paragraph: 5,
1042 offset: 0
1043 }; 2]
1044 );
1045 editor.insert(&mut engine, "Outside").unwrap();
1046 assert_eq!(
1047 editor
1048 .active_outline()
1049 .document
1050 .paragraphs()
1051 .last()
1052 .unwrap()
1053 .text(),
1054 "Outside"
1055 );
1056 editor.undo(&mut engine).unwrap();
1057 editor.undo(&mut engine).unwrap();
1058 assert_eq!(cells(&editor).len(), 6);
1059 }
1060
1061 #[test]
1062 fn enter_and_soft_break_stay_in_cells_until_the_final_end() {
1063 let mut engine = TextEngine::default();
1064 let mut editor = editor(&mut engine, "Alpha");
1065 editor
1066 .select(
1067 [TextPosition {
1068 paragraph: 0,
1069 offset: 5,
1070 }; 2]
1071 .into(),
1072 )
1073 .unwrap();
1074 editor.tab(&mut engine, false).unwrap();
1075 editor.insert(&mut engine, "Beta").unwrap();
1076 editor
1077 .select(
1078 [TextPosition {
1079 paragraph: 1,
1080 offset: 2,
1081 }; 2]
1082 .into(),
1083 )
1084 .unwrap();
1085 editor.enter(&mut engine, false).unwrap();
1086 assert_eq!(cells(&editor), [vec!["Alpha"], vec!["Be", "ta"]]);
1087 editor.enter(&mut engine, true).unwrap();
1088 assert_eq!(cells(&editor)[1], ["Be", "\u{000b}ta"]);
1089 editor
1090 .select(
1091 [TextPosition {
1092 paragraph: 0,
1093 offset: 5,
1094 }; 2]
1095 .into(),
1096 )
1097 .unwrap();
1098 editor.enter(&mut engine, false).unwrap();
1099 assert_eq!(cells(&editor)[0], ["Alpha", ""]);
1100 }
1101
1102 #[test]
1103 fn paragraph_start_and_selection_tab_indent_without_creating_a_table() {
1104 let mut engine = TextEngine::default();
1105 let mut editor = editor(&mut engine, "Alpha");
1106 editor.tab(&mut engine, false).unwrap();
1107 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
1108 editor
1109 .select(
1110 [
1111 TextPosition {
1112 paragraph: 0,
1113 offset: 1,
1114 },
1115 TextPosition {
1116 paragraph: 0,
1117 offset: 3,
1118 },
1119 ]
1120 .into(),
1121 )
1122 .unwrap();
1123 let selection = editor.selection();
1124 editor.tab(&mut engine, false).unwrap();
1125 assert_eq!(editor.active_outline().document.nodes()[0].level, 3);
1126 assert_eq!(
1127 editor
1128 .active_outline()
1129 .document
1130 .paragraphs()
1131 .next()
1132 .unwrap()
1133 .text(),
1134 "Alpha"
1135 );
1136 assert_eq!(editor.selection(), selection);
1137 editor.tab(&mut engine, true).unwrap();
1138 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
1139 editor
1140 .select(
1141 [TextPosition {
1142 paragraph: 0,
1143 offset: 5,
1144 }; 2]
1145 .into(),
1146 )
1147 .unwrap();
1148 editor.tab(&mut engine, false).unwrap();
1149 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
1150 assert_eq!(
1151 editor
1152 .active_outline()
1153 .document
1154 .text_nodes()
1155 .next()
1156 .unwrap()
1157 .level,
1158 1
1159 );
1160 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);
1161 }
1162
1163 fn calibri(engine: &mut TextEngine) -> CanvasEditor {
1164 let format = Format {
1165 font: Some("Calibri".into()),
1166 font_size: Some(11.0),
1167 ..Format::default()
1168 };
1169 CanvasEditor::new(
1170 engine,
1171 TextDocument::new(vec![Paragraph::new(String::new(), format)]).unwrap(),
1172 468.0,
1173 )
1174 .unwrap()
1175 }
1176
1177 fn table(editor: &CanvasEditor) -> &onestore::page::Table {
1178 let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content
1179 else {
1180 panic!()
1181 };
1182 table
1183 }
1184
1185 fn widths(editor: &CanvasEditor) -> Vec<f32> {
1186 table(editor)
1187 .columns
1188 .iter()
1189 .map(|column| column.width)
1190 .collect()
1191 }
1192
1193 fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) {
1194 for (index, row) in rows.iter().enumerate() {
1195 if index > 0 {
1196 editor.enter(engine, false).unwrap();
1197 }
1198 for (column, text) in row.iter().enumerate() {
1199 if column > 0 {
1200 editor.tab(engine, false).unwrap();
1201 }
1202 editor.insert(engine, text).unwrap();
1203 }
1204 }
1205 }
1206
1207 /// OneNote 2010's widths for the same table typed the same way (lab, 2026-09-30).
1208 #[test]
1209 fn typing_fits_unlocked_columns_to_their_widest_line() {
1210 let mut engine = TextEngine::default();
1211 let mut editor = calibri(&mut engine);
1212 type_rows(
1213 &mut editor,
1214 &mut engine,
1215 &[
1216 &["Fruit", "Colour", "Notes"],
1217 &["Apple", "Red", "Crisp and sweet, good for pies"],
1218 &["Watermelon", "Green", "Summer"],
1219 ],
1220 );
1221 for (width, onenote) in widths(&editor)
1222 .into_iter()
1223 .zip([60.75945, 37.11, 139.08636])
1224 {
1225 assert!((width - onenote).abs() < 0.01, "{width} against {onenote}");
1226 }
1227 // Deleting the widest line narrows the column to the next widest, here the minimum.
1228 let end = TextPosition {
1229 paragraph: 6,
1230 offset: 10,
1231 };
1232 editor
1233 .select([TextPosition { offset: 0, ..end }, end].into())
1234 .unwrap();
1235 editor.delete(&mut engine, true).unwrap();
1236 assert_eq!(widths(&editor)[0], COLUMN_WIDTH);
1237 assert!(editor.undo(&mut engine).unwrap());
1238 assert!((widths(&editor)[0] - 60.75945).abs() < 0.01);
1239 }
1240
1241 #[test]
1242 fn a_widening_keystroke_stores_its_width_with_its_text() {
1243 let mut engine = TextEngine::default();
1244 let mut editor = calibri(&mut engine);
1245 type_rows(&mut editor, &mut engine, &[&["A", ""]]);
1246 editor.take_ops().unwrap();
1247 let before = widths(&editor);
1248 editor.insert(&mut engine, "Watermelon").unwrap();
1249 let ops = editor.take_ops().unwrap();
1250 let columns = ops
1251 .iter()
1252 .filter_map(|op| match op {
1253 PageOp::Table {
1254 edit: onestore::op::TableEdit::Columns(columns),
1255 ..
1256 } => Some(
1257 columns
1258 .iter()
1259 .map(|column| column.width)
1260 .collect::<Vec<_>>(),
1261 ),
1262 _ => None,
1263 })
1264 .collect::<Vec<_>>();
1265 assert_eq!(columns, [widths(&editor)]);
1266 assert!(ops.len() > 1);
1267 assert!(widths(&editor)[1] > before[1]);
1268 // A keystroke the column already holds stores no width.
1269 editor.delete(&mut engine, true).unwrap();
1270 editor.insert(&mut engine, "n").unwrap();
1271 editor.take_ops().unwrap();
1272 editor
1273 .select(
1274 [TextPosition {
1275 paragraph: 0,
1276 offset: 1,
1277 }; 2]
1278 .into(),
1279 )
1280 .unwrap();
1281 editor.insert(&mut engine, "!").unwrap();
1282 assert!(!editor.take_ops().unwrap().iter().any(|op| matches!(
1283 op,
1284 PageOp::Table {
1285 edit: onestore::op::TableEdit::Columns(_),
1286 ..
1287 }
1288 )));
1289 }
1290
1291 /// OneNote 2010 stops a table at the outline's width and wraps the cell from there.
1292 #[test]
1293 fn a_table_stops_widening_at_the_outline_width() {
1294 let mut engine = TextEngine::default();
1295 let mut editor = calibri(&mut engine);
1296 type_rows(&mut editor, &mut engine, &[&["A", ""]]);
1297 for _ in 0..40 {
1298 editor.insert(&mut engine, "word ").unwrap();
1299 }
1300 let widths = widths(&editor);
1301 let table = widths.iter().map(|width| width + 4.98).sum::<f32>() - 1.83;
1302 assert!((table - 468.0).abs() < 0.01, "{table}");
1303 assert!(
1304 editor.active_outline().shaped.paragraphs[1]
1305 .text
1306 .lines()
1307 .count()
1308 > 1
1309 );
1310 }
1311
1312 #[test]
1313 fn a_dragged_column_locks_moves_the_columns_after_it_and_undoes_in_one_step() {
1314 let mut engine = TextEngine::default();
1315 let mut editor = calibri(&mut engine);
1316 type_rows(&mut editor, &mut engine, &[&["B", "Word"]]);
1317 let id = table(&editor).id;
1318 let fitted = widths(&editor);
1319 let cells = |editor: &CanvasEditor| {
1320 editor.active_outline().shaped.tables[0]
1321 .cells
1322 .iter()
1323 .map(|cell| cell.rect)
1324 .collect::<Vec<_>>()
1325 };
1326 let before = cells(&editor);
1327 // The border of the first column, from its cell's right edge.
1328 let border = [before[0][2], (before[0][1] + before[0][3]) / 2.0];
1329 assert_eq!(
1330 editor.active_outline().column_border(border, 2.0),
1331 Some((id, 0, fitted[0]))
1332 );
1333 assert_eq!(
1334 editor
1335 .active_outline()
1336 .column_border([border[0] - 10.0, border[1]], 2.0),
1337 None
1338 );
1339 let preview = editor.preview_column(&mut engine, id, 0, 88.86).unwrap();
1340 assert_eq!(widths(&editor), fitted);
1341 editor.take_ops().unwrap();
1342 let history = editor.undo.len();
1343 editor.resize_column(&mut engine, id, 0, 88.86).unwrap();
1344 assert_eq!(editor.undo.len(), history + 1);
1345 assert_eq!(
1346 table(&editor).columns[0],
1347 TableColumn {
1348 width: 88.86,
1349 locked: true
1350 }
1351 );
1352 assert_eq!(
1353 table(&editor).columns[1],
1354 TableColumn {
1355 width: fitted[1],
1356 locked: false
1357 }
1358 );
1359 assert_eq!(cells(&editor), {
1360 let ParagraphContent::Table(_) = &preview.document.nodes()[0].content else {
1361 panic!()
1362 };
1363 preview.shaped.tables[0]
1364 .cells
1365 .iter()
1366 .map(|cell| cell.rect)
1367 .collect::<Vec<_>>()
1368 });
1369 let moved = cells(&editor);
1370 assert!((moved[1][0] - before[1][0] - (88.86 - fitted[0])).abs() < 0.001);
1371 let ops = editor.take_ops().unwrap();
1372 assert!(
1373 matches!(
1374 ops.as_slice(),
1375 [PageOp::Table {
1376 edit: onestore::op::TableEdit::Columns(_),
1377 ..
1378 }]
1379 ),
1380 "{ops:?}"
1381 );
1382 // A locked column keeps its width while typing; dragging stops at a new column's width.
1383 editor
1384 .insert(&mut engine, " and a long line of text")
1385 .unwrap();
1386 assert_eq!(table(&editor).columns[0].width, 88.86);
1387 editor.resize_column(&mut engine, id, 1, 5.0).unwrap();
1388 assert_eq!(
1389 table(&editor).columns[1],
1390 TableColumn {
1391 width: COLUMN_WIDTH,
1392 locked: true
1393 }
1394 );
1395 for _ in 0..3 {
1396 editor.undo(&mut engine).unwrap();
1397 }
1398 assert_eq!(widths(&editor), fitted);
1399 assert!(table(&editor).columns.iter().all(|column| !column.locked));
1400 assert_eq!(cells(&editor), before);
1401 }
1402}