1//! Table columns fit their content as it is typed and lock where a border is dragged, each
2//! keystroke's width stored in the revision with its text, and OneNote 2010 reads them back
3//! as it sizes them itself (`corpus/table-widths`).
4
5use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
6use onestore::{
7 Arena, ExGuid, Section, Store,
8 op::{Edit, Op},
9 page::{Page, PageObject, PageParagraph, ParagraphContent, TableColumn},
10};
11
12const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one");
13
14/// Each table's first cell text and columns, in page order.
15fn tables(page: &Page) -> Vec<(String, Vec<TableColumn>)> {
16 fn walk(nodes: &[PageParagraph], out: &mut Vec<(String, Vec<TableColumn>)>) {
17 for node in nodes {
18 if let ParagraphContent::Table(table) = &node.content {
19 let first = table.rows[0].cells[0].paragraphs[0]
20 .text()
21 .map_or(String::new(), |text| text.text.text().to_owned());
22 out.push((first, table.columns.clone()));
23 for cell in table.rows.iter().flat_map(|row| &row.cells) {
24 walk(&cell.paragraphs, out);
25 }
26 }
27 }
28 }
29 let mut out = Vec::new();
30 for object in &page.objects {
31 if let PageObject::Outline(outline) = object {
32 walk(&outline.paragraphs, &mut out);
33 }
34 }
35 out
36}
37
38fn body(page: &Page) -> ExGuid {
39 page.objects
40 .iter()
41 .find_map(|object| match object {
42 PageObject::Outline(outline) if !outline.paragraphs.is_empty() => Some(outline.id),
43 _ => None,
44 })
45 .unwrap()
46}
47
48fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) {
49 let ops = editor
50 .take_ops()
51 .unwrap()
52 .into_iter()
53 .map(|op| Op::Page { space, op })
54 .collect();
55 section.apply("Author", &Edit { at, ops }).unwrap();
56}
57
58/// Opens `space` with the caret at the end of its body's last paragraph.
59fn at_end(section: &Section<'_>, space: ExGuid, engine: &mut TextEngine) -> CanvasEditor {
60 let page = section.page(space).unwrap();
61 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
62 editor.focus_outline(body(&page)).unwrap();
63 let document = editor.active_outline().document();
64 let paragraph = document.paragraphs().count() - 1;
65 let offset = document
66 .paragraphs()
67 .last()
68 .unwrap()
69 .text()
70 .encode_utf16()
71 .count() as u32;
72 editor
73 .select([TextPosition { paragraph, offset }; 2].into())
74 .unwrap();
75 editor
76}
77
78fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) {
79 for (index, row) in rows.iter().enumerate() {
80 if index > 0 {
81 editor.enter(engine, false).unwrap();
82 }
83 for (column, text) in row.iter().enumerate() {
84 if column > 0 {
85 editor.tab(engine, false).unwrap();
86 }
87 editor.insert(engine, text).unwrap();
88 }
89 }
90}
91
92fn close(columns: &[TableColumn], expected: &[(f32, bool)]) -> bool {
93 columns.len() == expected.len()
94 && columns
95 .iter()
96 .zip(expected)
97 .all(|(column, (width, locked))| {
98 (column.width - width).abs() < 0.01 && column.locked == *locked
99 })
100}
101
102/// `SNOWBOUND_TABLE_WIDTHS_EXPORT` names a new directory receiving the candidate for a cold
103/// reopen.
104#[test]
105fn typed_and_dragged_columns_store_onenote_s_widths() {
106 let arena = Arena::default();
107 let mut section = Section::open(&arena, TABLES.to_vec()).unwrap();
108 let pages = section.pages().unwrap();
109 let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0;
110 let (fruit, typed, dragged) = (
111 space("Check a table"),
112 space("Edit a tagged table"),
113 space("Delete and undo"),
114 );
115 let mut engine = TextEngine::default();
116 let mut at = 134_000_000_000_000_000;
117 let mut step = |section: &mut Section<'_>, space, editor: &mut CanvasEditor| {
118 at += 10_000_000;
119 publish(section, space, editor, at);
120 };
121
122 // A table typed after the page's text, one revision per keystroke.
123 let mut editor = at_end(&section, fruit, &mut engine);
124 editor.enter(&mut engine, false).unwrap();
125 editor.insert_table(&mut engine, 1, 1).unwrap();
126 step(&mut section, fruit, &mut editor);
127 for (index, row) in [
128 ["Fruit", "Colour", "Notes"],
129 ["Apple", "Red", "Crisp and sweet, good for pies"],
130 ["Watermelon", "Green", "Summer"],
131 ]
132 .iter()
133 .enumerate()
134 {
135 if index > 0 {
136 editor.enter(&mut engine, false).unwrap();
137 step(&mut section, fruit, &mut editor);
138 }
139 for (column, text) in row.iter().enumerate() {
140 if column > 0 {
141 editor.tab(&mut engine, false).unwrap();
142 step(&mut section, fruit, &mut editor);
143 }
144 for character in text.chars() {
145 editor.insert(&mut engine, &character.to_string()).unwrap();
146 step(&mut section, fruit, &mut editor);
147 }
148 }
149 }
150 let stored = tables(&section.page(fruit).unwrap());
151 // OneNote 2010 typing the same table (lab, 2026-09-30).
152 assert!(
153 close(
154 &stored[1].1,
155 &[(60.759, false), (37.11, false), (139.086, false)]
156 ),
157 "{stored:?}"
158 );
159
160 // Typing into a native table's cell widens its column; deleting narrows it again.
161 let mut editor = CanvasEditor::from_page(section.page(typed).unwrap(), &mut engine).unwrap();
162 editor
163 .focus_outline(body(&section.page(typed).unwrap()))
164 .unwrap();
165 editor
166 .select(
167 [TextPosition {
168 paragraph: 1,
169 offset: 10,
170 }; 2]
171 .into(),
172 )
173 .unwrap();
174 editor
175 .insert(&mut engine, " and typed on in Snowbound")
176 .unwrap();
177 step(&mut section, typed, &mut editor);
178 let widened = tables(&section.page(typed).unwrap());
179 assert!(widened[0].1[0].width > 100.0, "{widened:?}");
180
181 // A dragged column locks and keeps its width while text wraps in it, and the column
182 // after it moves; a long line in that column stops at the outline's width.
183 let mut editor = at_end(&section, dragged, &mut engine);
184 editor.enter(&mut engine, false).unwrap();
185 editor.insert_table(&mut engine, 1, 1).unwrap();
186 type_rows(&mut editor, &mut engine, &[&["Dragged", "Fitted"]]);
187 step(&mut section, dragged, &mut editor);
188 let table = editor
189 .active_outline()
190 .document()
191 .nodes()
192 .iter()
193 .find_map(|node| match &node.content {
194 ParagraphContent::Table(table)
195 if table.rows[0].cells[0].paragraphs[0]
196 .text()
197 .is_some_and(|text| text.text.text() == "Dragged") =>
198 {
199 Some(table.id)
200 }
201 _ => None,
202 })
203 .unwrap();
204 editor.resize_column(&mut engine, table, 0, 96.0).unwrap();
205 step(&mut section, dragged, &mut editor);
206 editor
207 .insert(
208 &mut engine,
209 " and then a line long enough to reach the outline's edge and wrap there",
210 )
211 .unwrap();
212 step(&mut section, dragged, &mut editor);
213 let resized = tables(&section.page(dragged).unwrap());
214 assert!(
215 resized[2].1[0].locked && resized[2].1[0].width == 96.0,
216 "{resized:?}"
217 );
218 assert!(
219 !resized[2].1[1].locked && resized[2].1[1].width > 200.0,
220 "{resized:?}"
221 );
222
223 section.seal().unwrap();
224 let written = section.image();
225 let reread = Section::open(&arena, written.clone()).unwrap();
226 assert_eq!(tables(&reread.page(fruit).unwrap()), stored);
227 assert_eq!(tables(&reread.page(typed).unwrap()), widened);
228 assert_eq!(tables(&reread.page(dragged).unwrap()), resized);
229 if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_WIDTHS_EXPORT") {
230 let directory = std::path::PathBuf::from(directory);
231 std::fs::create_dir(&directory).unwrap();
232 std::fs::write(directory.join("Tables.one"), &written).unwrap();
233 let file_id = Store::parse(&written).unwrap().header.file_id;
234 std::fs::write(
235 directory.join("Open Notebook.onetoc2"),
236 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)])
237 .unwrap(),
238 )
239 .unwrap();
240 }
241}