authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 22:23:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log05027137f9f30b0179a4f73df88c69aaf898d2de
tree93f6689dc514d87ffd6ba74ad096c25c53a7dfa0
parent263f6a1b7979ef2640fe4d7cfba53c4b9974b442
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: structural edits everywhere, all nine tags, and pages that always open

Enter, Backspace, Delete, Tab and Shift-Tab, lists and multi-line paste work in outlines with tags, lists, nesting and collapsed paragraphs, following OneNote 2010 keystroke by keystroke (evidence replayed in editor/evidence.rs): Enter never carries a tag, the upper paragraph wins a join, and bullets restyle as they indent. The toolbar applies all nine default tags, drawn from one set of tag art in canvas, and paste goes through the editor's paste. Pages our writer created open again (a missing indent table takes OneNote's default and either title flag marks the title), pictures that won't decode show a placeholder and save unchanged, and TIFFs decode. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

28 files changed, 2092 insertions(+), 344 deletions(-)

Cargo.lock+27
...@@ -792,6 +792,12 @@ version = "2.5.0"...@@ -792,6 +792,12 @@ version = "2.5.0"
792source = "registry+https://github.com/rust-lang/crates.io-index"792source = "registry+https://github.com/rust-lang/crates.io-index"
793checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"793checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
794794
795[[package]]
796name = "fax"
797version = "0.2.7"
798source = "registry+https://github.com/rust-lang/crates.io-index"
799checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
800
795[[package]]801[[package]]
796name = "fdeflate"802name = "fdeflate"
797version = "0.3.7"803version = "0.3.7"
...@@ -1229,6 +1235,7 @@ dependencies = [...@@ -1229,6 +1235,7 @@ dependencies = [
1229 "moxcms",1235 "moxcms",
1230 "num-traits",1236 "num-traits",
1231 "png",1237 "png",
1238 "tiff",
1232 "zune-core",1239 "zune-core",
1233 "zune-jpeg",1240 "zune-jpeg",
1234]1241]
...@@ -2282,6 +2289,12 @@ version = "0.1.30"...@@ -2282,6 +2289,12 @@ version = "0.1.30"
2282source = "registry+https://github.com/rust-lang/crates.io-index"2289source = "registry+https://github.com/rust-lang/crates.io-index"
2283checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"2290checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
22842291
2292[[package]]
2293name = "quick-error"
2294version = "2.0.1"
2295source = "registry+https://github.com/rust-lang/crates.io-index"
2296checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2297
2285[[package]]2298[[package]]
2286name = "quick-xml"2299name = "quick-xml"
2287version = "0.41.0"2300version = "0.41.0"
...@@ -2927,6 +2940,20 @@ dependencies = [...@@ -2927,6 +2940,20 @@ dependencies = [
2927 "syn 3.0.5",2940 "syn 3.0.5",
2928]2941]
29292942
2943[[package]]
2944name = "tiff"
2945version = "0.11.3"
2946source = "registry+https://github.com/rust-lang/crates.io-index"
2947checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
2948dependencies = [
2949 "fax",
2950 "flate2",
2951 "half",
2952 "quick-error",
2953 "weezl",
2954 "zune-jpeg",
2955]
2956
2930[[package]]2957[[package]]
2931name = "tiny-skia"2958name = "tiny-skia"
2932version = "0.11.4"2959version = "0.11.4"
crates/canvas/assets/tags/address.svg created+34
...@@ -0,0 +1,34 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#9a4a2a"/>
5 <stop offset="1" stop-color="#7a3218"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#b58a3a"/>
9 <stop offset="1" stop-color="#8a6420"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#fff1b0"/>
13 <stop offset="1" stop-color="#f2c25c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e0672e"/>
17 <stop offset="1" stop-color="#a8401a"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#9a5a1e"/>
21 <stop offset="1" stop-color="#6e3a10"/>
22 </linearGradient>
23 <linearGradient id="layer5" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
24 <stop offset="0" stop-color="#e3a54c"/>
25 <stop offset="1" stop-color="#c07a26"/>
26 </linearGradient>
27 </defs>
28 <path d="M10.5 1.5H13V5.5L10.5 3.5Z" fill="url(#layer0)"/>
29 <path d="M3 7H13V15H3Z" fill="url(#layer1)"/>
30 <path d="M4 7.5H12V14H4Z" fill="url(#layer2)"/>
31 <path d="M8 0.75L15.25 8L14 9.25L8 3.25L2 9.25L0.75 8Z" fill="url(#layer3)"/>
32 <path d="M6.75 9.5H9.25V14H6.75Z" fill="url(#layer4)"/>
33 <path d="M7.25 10H8.75V14H7.25Z" fill="url(#layer5)"/>
34</svg>
crates/canvas/assets/tags/contact.svg created+25
...@@ -0,0 +1,25 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#8aa3c8"/>
5 <stop offset="1" stop-color="#5d7aa6"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#d3e0f2"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#e39a5c"/>
13 <stop offset="1" stop-color="#b8612a"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e05a30"/>
17 <stop offset="1" stop-color="#b03a1a"/>
18 </linearGradient>
19 </defs>
20 <path d="M1.5 2.5H14.5A1 1 0 0 1 15.5 3.5V12.5A1 1 0 0 1 14.5 13.5H1.5A1 1 0 0 1 0.5 12.5V3.5A1 1 0 0 1 1.5 2.5Z" fill="url(#layer0)"/>
21 <path d="M1.75 3.5H14.25A0.25 0.25 0 0 1 14.5 3.75V12.25A0.25 0.25 0 0 1 14.25 12.5H1.75A0.25 0.25 0 0 1 1.5 12.25V3.75A0.25 0.25 0 0 1 1.75 3.5Z" fill="url(#layer1)"/>
22 <path d="M3.5 6.5A1.75 1.75 0 1 0 7 6.5A1.75 1.75 0 1 0 3.5 6.5Z" fill="url(#layer2)"/>
23 <path d="M2.25 11.75C2.25 9.5 3.6 8.75 5.25 8.75C6.9 8.75 8.25 9.5 8.25 11.75Z" fill="url(#layer3)"/>
24 <path d="M9.5 5.5H13.5V6.75H9.5ZM9.5 8H13.5V9.25H9.5ZM9.5 10.5H12.5V11.75H9.5Z" fill="#4d6d9c"/>
25</svg>
crates/canvas/assets/tags/highlight.svg created+29
...@@ -0,0 +1,29 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#b23a64"/>
5 <stop offset="1" stop-color="#8c2248"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#7a2a40"/>
9 <stop offset="1" stop-color="#5a1a2c"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ff9ec0"/>
13 <stop offset="1" stop-color="#e0487c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e76b98"/>
17 <stop offset="1" stop-color="#c0305e"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#ff5f8f"/>
21 <stop offset="1" stop-color="#e0205a"/>
22 </linearGradient>
23 </defs>
24 <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/>
25 <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/>
26 <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/>
27 <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/>
28 <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/>
29</svg>
crates/canvas/assets/tags/phone.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#7f8b99"/>
5 <stop offset="1" stop-color="#56606d"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#f4f7fb"/>
9 <stop offset="1" stop-color="#b3c1d3"/>
10 </linearGradient>
11 </defs>
12 <path d="M12.86 14.11L12.59 14.07L12.31 14.03L12.04 13.98L11.77 13.92L11.49 13.86L11.22 13.79L10.96 13.72L10.69 13.64L10.42 13.56L10.16 13.46L9.9 13.37L9.64 13.27L9.38 13.16L9.13 13.04L8.88 12.92L8.63 12.8L8.38 12.67L8.14 12.53L7.9 12.39L7.66 12.25L7.43 12.09L7.2 11.94L6.97 11.78L6.75 11.61L6.53 11.44L6.31 11.26L6.1 11.08L5.89 10.89L5.69 10.7L5.49 10.51L5.3 10.31L5.11 10.11L4.92 9.9L4.74 9.69L4.56 9.47L4.39 9.25L4.22 9.03L4.06 8.8L3.91 8.57L3.75 8.34L3.61 8.1L3.47 7.86L3.33 7.62L3.2 7.37L3.08 7.12L2.96 6.87L2.84 6.62L2.73 6.36L2.63 6.1L2.54 5.84L2.44 5.58L2.36 5.31L2.28 5.04L2.21 4.78L2.14 4.51L2.08 4.23L2.02 3.96L1.97 3.69L1.93 3.41L1.89 3.14L7.45 2.45L7.47 2.61L7.5 2.76L7.52 2.91L7.55 3.06L7.59 3.21L7.63 3.36L7.67 3.51L7.71 3.66L7.76 3.81L7.81 3.96L7.86 4.1L7.77 4.3L7.44 4.62L6.98 5.02L6.51 5.45L6.15 5.85L5.99 6.17L6.07 6.37L6.18 6.55L6.29 6.73L6.4 6.91L6.52 7.09L6.65 7.26L6.77 7.43L6.9 7.6L7.04 7.76L7.18 7.92L7.32 8.08L7.46 8.24L7.61 8.39L7.76 8.54L7.92 8.68L8.08 8.82L8.24 8.96L8.4 9.1L8.57 9.23L8.74 9.35L8.91 9.48L9.09 9.6L9.27 9.71L9.45 9.82L9.63 9.93L9.83 10.01L10.15 9.85L10.55 9.49L10.98 9.02L11.38 8.56L11.7 8.23L11.9 8.14L12.04 8.19L12.19 8.24L12.34 8.29L12.49 8.33L12.64 8.37L12.79 8.41L12.94 8.45L13.09 8.48L13.24 8.5L13.39 8.53L13.55 8.55Z" fill="url(#layer0)"/>
13 <path d="M12.13 13.15L12.71 13.23L12.45 13.19L12.2 13.14L11.94 13.09L11.69 13.03L11.44 12.97L11.19 12.9L10.94 12.83L10.69 12.75L10.45 12.66L10.2 12.57L9.96 12.48L9.72 12.38L9.48 12.27L9.25 12.16L9.02 12.04L8.79 11.92L8.56 11.8L8.34 11.66L8.12 11.53L7.9 11.39L7.68 11.24L7.47 11.09L7.26 10.93L7.06 10.77L6.86 10.61L6.66 10.44L6.47 10.27L6.28 10.09L6.09 9.91L5.91 9.72L5.73 9.53L5.56 9.34L5.39 9.14L5.23 8.94L5.07 8.74L4.91 8.53L4.76 8.32L4.61 8.1L4.47 7.88L4.34 7.66L4.2 7.44L4.08 7.21L3.96 6.98L3.84 6.75L3.73 6.52L3.62 6.28L3.52 6.04L3.43 5.8L3.34 5.55L3.25 5.31L3.17 5.06L3.1 4.81L3.03 4.56L2.97 4.31L2.91 4.06L2.86 3.8L2.81 3.55L2.77 3.29L2.85 3.87L6.72 3.4L6.63 2.73L6.66 2.9L6.69 3.07L6.72 3.24L6.76 3.41L6.8 3.58L6.85 3.75L6.9 3.91L6.95 4.08L7.01 4.24L6.95 4.07L7.06 3.81L6.87 3.99L6.41 4.38L5.91 4.85L5.43 5.37L5.06 6.14L5.31 6.75L5.45 6.99L5.57 7.19L5.7 7.38L5.83 7.57L5.96 7.76L6.1 7.95L6.24 8.13L6.39 8.31L6.54 8.48L6.69 8.66L6.85 8.82L7.01 8.99L7.18 9.15L7.34 9.31L7.52 9.46L7.69 9.61L7.87 9.76L8.05 9.9L8.24 10.04L8.43 10.17L8.62 10.3L8.81 10.43L9.01 10.55L9.25 10.69L9.86 10.94L10.63 10.57L11.15 10.09L11.62 9.59L12.01 9.13L12.19 8.94L11.93 9.05L11.76 8.99L11.92 9.05L12.09 9.1L12.25 9.15L12.42 9.2L12.59 9.24L12.76 9.28L12.93 9.31L13.1 9.34L13.27 9.37L12.6 9.28Z" fill="url(#layer1)"/>
14</svg>
crates/canvas/assets/tags/star.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#c8a049"/>
5 <stop offset="1" stop-color="#a57a2c"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#fff4c4"/>
9 <stop offset="1" stop-color="#f1c35a"/>
10 </linearGradient>
11 </defs>
12 <path d="M8 1.35L9.94 6.08L15.04 6.46L11.14 9.77L12.35 14.74L8 12.05L3.65 14.74L4.86 9.77L0.96 6.46L6.06 6.08Z" fill="url(#layer0)"/>
13 <path d="M8 3.72L9.32 6.94L12.78 7.2L10.13 9.44L10.96 12.82L8 10.99L5.04 12.82L5.87 9.44L3.22 7.2L6.68 6.94Z" fill="url(#layer1)"/>
14</svg>
crates/canvas/src/date.rs+11-7
...@@ -72,13 +72,17 @@ impl PageDate {...@@ -72,13 +72,17 @@ impl PageDate {
72 if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 {72 if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 {
73 return Err(EditError::UnsupportedContent.into());73 return Err(EditError::UnsupportedContent.into());
74 }74 }
75 let document = TextDocument::from_nodes(source.paragraphs.clone())?;75 TextDocument::from_nodes(source.paragraphs.clone())?;
76 document.validate_flat()?;76 // A date field is one plain run in a plain paragraph.
77 if source77 if source.paragraphs.iter().any(|paragraph| {
78 .paragraphs78 !paragraph.lists.is_empty()
79 .iter()79 || !paragraph.tags.is_empty()
80 .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1)80 || paragraph.collapsed
81 {81 || paragraph.parent.is_some()
82 || paragraph
83 .text()
84 .is_none_or(|text| !text.tags.is_empty() || text.text.spans().len() != 1)
85 }) {
82 return Err(EditError::UnsupportedContent.into());86 return Err(EditError::UnsupportedContent.into());
83 }87 }
84 let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;88 let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;
crates/canvas/src/document.rs+284-52
...@@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize>...@@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize>
243 })243 })
244}244}
245245
246pub(crate) fn validate_flat<'a>(246/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
247 mut nodes: impl Iterator<Item = &'a PageParagraph>,247/// contiguously; a paragraph only an outline group indents has no parent to descend from.
248) -> Result<(), EditError> {248pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
249 if nodes.any(|node| {249 let mut members = BTreeSet::from([nodes[index].id]);
250 !node.lists.is_empty()250 index
251 || !node.tags.is_empty()251 + 1
252 || node.collapsed252 + nodes[index + 1..]
253 || node.parent.is_some()253 .iter()
254 || node.text().is_none_or(|text| !text.tags.is_empty())254 .take_while(|node| {
255 }) {255 node.parent.is_some_and(|parent| members.contains(&parent))
256 return Err(EditError::UnsupportedContent);256 && members.insert(node.id)
257 })
258 .count()
259}
260
261/// The nodes from `from` on that descend from a paragraph in `moves` or `shifts`, rebuilt so
262/// the children of each paragraph in `moves` belong to its new parent, one level below it, and
263/// every subtree keeps its depth below its root; ends at the last node that changes.
264fn adopt(
265 nodes: &[PageParagraph],
266 from: usize,
267 moves: &BTreeMap<ExGuid, &PageParagraph>,
268 mut shifts: BTreeMap<ExGuid, i64>,
269) -> Result<Vec<PageParagraph>, EditError> {
270 let mut adopted = Vec::new();
271 let mut changed = 0;
272 for node in &nodes[from..] {
273 let Some(parent) = node.parent else { break };
274 let mut node = node.clone();
275 let shift = match (moves.get(&parent), shifts.get(&parent)) {
276 (Some(holder), _) => {
277 node.parent = Some(holder.id);
278 i64::from(holder.level) + 1 - i64::from(node.level)
279 }
280 (None, Some(shift)) => *shift,
281 (None, None) => break,
282 };
283 node.level = u32::try_from(i64::from(node.level) + shift)
284 .map_err(|_| EditError::InvalidStructure)?;
285 shifts.insert(node.id, shift);
286 if shift != 0 || moves.contains_key(&parent) {
287 changed = adopted.len() + 1;
288 }
289 adopted.push(node);
257 }290 }
258 Ok(())291 adopted.truncate(changed);
292 Ok(adopted)
293}
294
295/// The sibling `nodes[index]` follows, passing over `skipped` siblings, when everything
296/// between them descends from it or from a skipped paragraph.
297pub(crate) fn previous_sibling(
298 nodes: &[PageParagraph],
299 index: usize,
300 skipped: &BTreeSet<ExGuid>,
301) -> Option<usize> {
302 let node = &nodes[index];
303 let sibling = (0..index).rev().find(|&at| {
304 nodes[at].level <= node.level
305 && !(nodes[at].level == node.level && skipped.contains(&nodes[at].id))
306 })?;
307 if nodes[sibling].level != node.level || nodes[sibling].parent != node.parent {
308 return None;
309 }
310 let mut members = BTreeSet::from([nodes[sibling].id]);
311 nodes[sibling + 1..index]
312 .iter()
313 .all(|node| {
314 let inside = skipped.contains(&node.id)
315 || node.parent.is_some_and(|parent| members.contains(&parent));
316 members.insert(node.id);
317 inside
318 })
319 .then_some(sibling)
320}
321
322/// Tab or Shift+Tab on `range` of `nodes` as OneNote does, moving each paragraph with its
323/// subtree: indenting makes a paragraph the last child of its previous sibling, or without one
324/// indents it within its group; outdenting a child makes it its parent's sibling, adopting the
325/// siblings after it. None when nothing moves.
326pub(crate) fn indent(
327 nodes: &[PageParagraph],
328 container: Option<ExGuid>,
329 range: Range<usize>,
330 outdent: bool,
331) -> Option<DocumentEdit> {
332 let selected = nodes[range.clone()]
333 .iter()
334 .map(|node| node.id)
335 .collect::<BTreeSet<_>>();
336 let mut end = range.end;
337 let mut tops = BTreeMap::new();
338 let mut adopters = BTreeMap::new();
339 for index in range.clone() {
340 let node = &nodes[index];
341 if node.parent.is_some_and(|parent| selected.contains(&parent)) {
342 continue;
343 }
344 end = end.max(subtree_end(nodes, index));
345 if !outdent {
346 let parent = previous_sibling(nodes, index, &selected).map(|at| nodes[at].id);
347 tops.insert(node.id, (parent.or(node.parent), 1));
348 } else if node.level > 1 {
349 let parent = node
350 .parent
351 .and_then(|id| nodes[..index].iter().rposition(|node| node.id == id))
352 .filter(|&at| nodes[at].level + 1 == node.level);
353 let parent = match parent {
354 Some(at) => {
355 end = end.max(subtree_end(nodes, at));
356 adopters.insert(nodes[at].id, node.id);
357 nodes[at].parent
358 }
359 None => node.parent,
360 };
361 tops.insert(node.id, (parent, -1));
362 }
363 }
364 if tops.is_empty() {
365 return None;
366 }
367 let mut shifts = BTreeMap::new();
368 let replacement = nodes[range.start..end]
369 .iter()
370 .map(|node| {
371 let mut node = node.clone();
372 let shift = match (tops.get(&node.id), node.parent) {
373 (Some(&(parent, shift)), _) => {
374 node.parent = parent;
375 shift
376 }
377 (None, Some(parent))
378 if !selected.contains(&node.id) && adopters.contains_key(&parent) =>
379 {
380 node.parent = Some(adopters[&parent]);
381 0
382 }
383 (None, parent) => parent
384 .and_then(|parent| shifts.get(&parent).copied())
385 .unwrap_or(0),
386 };
387 node.level = node.level.saturating_add_signed(shift);
388 shifts.insert(node.id, shift);
389 node
390 })
391 .collect();
392 Some(DocumentEdit {
393 columns: BTreeMap::new(),
394 container,
395 range: range.start..end,
396 replacement,
397 })
259}398}
260399
261pub(crate) fn container_mut(400pub(crate) fn container_mut(
...@@ -318,10 +457,6 @@ impl TextDocument {...@@ -318,10 +457,6 @@ impl TextDocument {
318 &self.nodes457 &self.nodes
319 }458 }
320459
321 pub(crate) fn validate_flat(&self) -> Result<(), EditError> {
322 validate_flat(self.nodes.iter())
323 }
324
325 pub fn text_nodes(&self) -> impl Iterator<Item = &PageParagraph> {460 pub fn text_nodes(&self) -> impl Iterator<Item = &PageParagraph> {
326 leaves(&self.nodes, None).map(|(_, _, node)| node)461 leaves(&self.nodes, None).map(|(_, _, node)| node)
327 }462 }
...@@ -452,6 +587,11 @@ impl TextDocument {...@@ -452,6 +587,11 @@ impl TextDocument {
452 Ok(result)587 Ok(result)
453 }588 }
454589
590 /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
591 /// its identity and properties, and a paragraph the replacement adds takes its level,
592 /// parent, style and lists but no note tags, except that a range's last paragraph stays
593 /// itself when the replacement ends in one. The last paragraph holds the children of the
594 /// paragraphs the edit removes or splits.
455 pub(crate) fn replace(595 pub(crate) fn replace(
456 &self,596 &self,
457 range: Range<TextPosition>,597 range: Range<TextPosition>,
...@@ -469,13 +609,7 @@ impl TextDocument {...@@ -469,13 +609,7 @@ impl TextDocument {
469 if container != end_container {609 if container != end_container {
470 return Err(EditError::UnsupportedContent);610 return Err(EditError::UnsupportedContent);
471 }611 }
472 if range.start.paragraph != range.end.paragraph || replacement.len() != 1 {612 let nodes = self.container(container)?;
473 validate_flat(self.container(container)?.iter().enumerate().filter_map(
474 |(index, node)| {
475 ((start..=end).contains(&index) || node.text().is_some()).then_some(node)
476 },
477 ))?;
478 }
479 let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;613 let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;
480 let last_text = &last.text().unwrap().text;614 let last_text = &last.text().unwrap().text;
481 let suffix =615 let suffix =
...@@ -484,32 +618,90 @@ impl TextDocument {...@@ -484,32 +618,90 @@ impl TextDocument {
484 prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;618 prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;
485 let mut head = first.clone();619 let mut head = first.clone();
486 head.text_mut().unwrap().text = prefix;620 head.text_mut().unwrap().text = prefix;
487 let mut nodes = vec![head];
488 let following = replacement.len();621 let following = replacement.len();
622 let keeps_last = start != end && following > 0;
623 let mut added = Vec::new();
489 for (index, text) in replacement.enumerate() {624 for (index, text) in replacement.enumerate() {
490 if index + 1 == following && range.start.paragraph != range.end.paragraph {625 let mut next = if keeps_last && index + 1 == following {
491 let mut end = last.clone();626 let mut end = last.clone();
492 end.text_mut().unwrap().text = text;627 end.text_mut().unwrap().text = text;
493 nodes.push(end);628 end
494 } else {629 } else {
495 let mut next = node(text, first.format.clone())?;630 let mut next = node(text, first.format.clone())?;
496 next.level = first.level;
497 next.style = first.style;631 next.style = first.style;
498 nodes.push(next);632 next.lists.clone_from(&first.lists);
499 }633 next
634 };
635 next.parent = first.parent;
636 next.level = first.level;
637 added.push(next);
638 }
639 if let Some(tail) = added.last_mut() {
640 tail.collapsed |= std::mem::take(&mut head.collapsed);
500 }641 }
642 let tail = added.last_mut().unwrap_or(&mut head);
501 if !suffix.text().is_empty() {643 if !suffix.text().is_empty() {
502 let end = nodes.last_mut().unwrap();644 tail.text_mut().unwrap().text.append(suffix)?;
503 end.text_mut().unwrap().text.append(suffix)?;645 }
646 let tail = added.last().unwrap_or(&head);
647 let mut moves = nodes[start + 1..end + usize::from(!keeps_last)]
648 .iter()
649 .map(|node| (node.id, tail))
650 .collect::<BTreeMap<_, _>>();
651 if tail.id != head.id {
652 moves.insert(head.id, tail);
504 }653 }
654 let shifts = keeps_last
655 .then(|| (last.id, i64::from(first.level) - i64::from(last.level)))
656 .into_iter()
657 .collect();
658 let adopted = adopt(nodes, end + 1, &moves, shifts)?;
505 Ok(DocumentEdit {659 Ok(DocumentEdit {
506 columns: BTreeMap::new(),660 columns: BTreeMap::new(),
507 container,661 container,
508 range: start..end + 1,662 range: start..end + 1 + adopted.len(),
509 replacement: nodes,663 replacement: [head].into_iter().chain(added).chain(adopted).collect(),
510 })664 })
511 }665 }
512666
667 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
668 /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
669 /// lies between them in one container.
670 pub(crate) fn join(
671 &self,
672 upper: usize,
673 lower: usize,
674 ) -> Result<Option<DocumentEdit>, EditError> {
675 let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?;
676 let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?;
677 let nodes = self.container(container)?;
678 if container != end_container
679 || last <= first
680 || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last)
681 {
682 return Ok(None);
683 }
684 let mut head = top.clone();
685 let text = head.text_mut().unwrap();
686 // OneNote moves the lower text object into an emptied upper paragraph.
687 if text.text.text().is_empty() {
688 text.id = bottom.text().unwrap().id;
689 }
690 text.text.append(bottom.text().unwrap().text.clone())?;
691 let moves = BTreeMap::from([(bottom.id, &head)]);
692 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;
693 Ok(Some(DocumentEdit {
694 columns: BTreeMap::new(),
695 container,
696 range: first..last + 1 + adopted.len(),
697 replacement: [head]
698 .into_iter()
699 .chain(nodes[first + 1..last].iter().cloned())
700 .chain(adopted)
701 .collect(),
702 }))
703 }
704
513 /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or705 /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or
514 /// whose column widths or text are invalid, looking only where the edit can conflict.706 /// whose column widths or text are invalid, looking only where the edit can conflict.
515 pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> {707 pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> {
...@@ -899,13 +1091,21 @@ mod tests {...@@ -899,13 +1091,21 @@ mod tests {
899 assert_eq!(document.nodes()[1], original.nodes()[1]);1091 assert_eq!(document.nodes()[1], original.nodes()[1]);
900 document.apply(undo).unwrap();1092 document.apply(undo).unwrap();
901 assert_eq!(document, original);1093 assert_eq!(document, original);
902 assert_eq!(1094 let edit = document
903 document.replace(1095 .replace(
904 position(0, 0)..position(4, 0),1096 position(0, 0)..position(4, 0),
905 vec![Paragraph::new(String::new(), Format::default())]1097 vec![Paragraph::new(String::new(), Format::default())],
906 ),1098 )
907 Err(EditError::UnsupportedContent)1099 .unwrap();
1100 document.apply(edit).unwrap();
1101 assert_eq!(
1102 document
1103 .paragraphs()
1104 .map(Paragraph::text)
1105 .collect::<Vec<_>>(),
1106 ["after"]
908 );1107 );
1108 assert_eq!(document.nodes().len(), 1);
909 }1109 }
9101110
911 #[test]1111 #[test]
...@@ -1221,7 +1421,7 @@ mod tests {...@@ -1221,7 +1421,7 @@ mod tests {
1221 }1421 }
12221422
1223 #[test]1423 #[test]
1224 fn editing_nested_tagged_text_preserves_metadata_and_refuses_structural_changes() {1424 fn editing_nested_tagged_text_preserves_metadata_through_structural_changes() {
1225 let mut nodes = TextDocument::new(1425 let mut nodes = TextDocument::new(
1226 ["parent", "a🌳e\u{301}z", "child"]1426 ["parent", "a🌳e\u{301}z", "child"]
1227 .into_iter()1427 .into_iter()
...@@ -1284,19 +1484,51 @@ mod tests {...@@ -1284,19 +1484,51 @@ mod tests {
1284 assert_eq!(document, edited);1484 assert_eq!(document, edited);
1285 }1485 }
1286 }1486 }
1287 for (range, count) in [1487 let structure = |range: Range<TextPosition>, count| {
1288 (position(1, 0)..position(1, 0), 2),1488 let mut document = original.clone();
1289 (position(0, 6)..position(1, 0), 1),1489 let edit = document
1290 (position(1, 0)..position(2, 0), 1),1490 .replace(
1291 ] {
1292 assert_eq!(
1293 original.replace(
1294 range,1491 range,
1295 vec![Paragraph::new(String::new(), Format::default()); count]1492 vec![Paragraph::new(String::new(), Format::default()); count],
1296 ),1493 )
1297 Err(EditError::UnsupportedContent)1494 .unwrap();
1298 );1495 let undo = document.apply(edit).unwrap();
1299 }1496 let edited = document.nodes().to_vec();
1497 document.apply(undo).unwrap();
1498 assert_eq!(document, original);
1499 edited
1500 };
1501 let [parent, item, child] = original.nodes() else {
1502 unreachable!()
1503 };
1504 // The new half takes the list, the children and their collapsed state, not the tags.
1505 let split = structure(position(1, 0)..position(1, 0), 2);
1506 assert_eq!(split[1].id, item.id);
1507 assert!(split[1].text().unwrap().text.text().is_empty() && !split[1].collapsed);
1508 assert_eq!(split[1].tags, item.tags);
1509 let tail = &split[2];
1510 assert_eq!(tail.text().unwrap().text, item.text().unwrap().text);
1511 assert_eq!((tail.parent, tail.level), (Some(parent.id), 2));
1512 assert_eq!(tail.lists, item.lists);
1513 assert!(tail.tags.is_empty() && tail.text().unwrap().tags.is_empty() && tail.collapsed);
1514 assert_eq!((split[3].parent, split[3].level), (Some(tail.id), 3));
1515 // The upper paragraph wins a join and adopts the lower one's children.
1516 let joined = structure(position(0, 6)..position(1, 0), 1);
1517 assert_eq!(joined[0].text().unwrap().text.text(), "parenta🌳e\u{301}z");
1518 assert_eq!((joined[0].id, joined[0].lists.len()), (parent.id, 0));
1519 assert_eq!(
1520 (joined[1].id, joined[1].parent, joined[1].level),
1521 (child.id, Some(parent.id), 2)
1522 );
1523 let joined = structure(position(1, 6)..position(2, 0), 1);
1524 assert_eq!(joined.len(), 2);
1525 assert_eq!(
1526 PageParagraph {
1527 content: item.content.clone(),
1528 ..joined[1].clone()
1529 },
1530 *item
1531 );
1300 }1532 }
13011533
1302 #[test]1534 #[test]
...@@ -1365,7 +1597,7 @@ mod tests {...@@ -1365,7 +1597,7 @@ mod tests {
1365 .unwrap();1597 .unwrap();
1366 for (original, regions) in [1598 for (original, regions) in [
1367 (original, vec![0, 0, 0]),1599 (original, vec![0, 0, 0]),
1368 (table_document(), vec![0, 1, 1, 2, 3]),1600 (table_document(), vec![0, 1, 1, 2, 0]),
1369 ] {1601 ] {
1370 let positions: Vec<_> = original1602 let positions: Vec<_> = original
1371 .paragraphs()1603 .paragraphs()
crates/canvas/src/editor.rs+348-99
...@@ -6,6 +6,7 @@ use crate::{...@@ -6,6 +6,7 @@ use crate::{
6};6};
7use draw::edit::{self, Movement, SelectionUnit};7use draw::edit::{self, Movement, SelectionUnit};
8use onestore::ExGuid;8use onestore::ExGuid;
9use onestore::document::{Format, Kind};
9use onestore::page::text::{EditError, Paragraph};10use onestore::page::text::{EditError, Paragraph};
10use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};11use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};
11use parley::{12use parley::{
...@@ -20,6 +21,8 @@ use std::{...@@ -20,6 +21,8 @@ use std::{
2021
21pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;22pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2223
24#[cfg(test)]
25mod evidence;
23mod format;26mod format;
24pub(crate) mod page;27pub(crate) mod page;
25mod table;28mod table;
...@@ -126,22 +129,20 @@ pub struct TextOutline {...@@ -126,22 +129,20 @@ pub struct TextOutline {
126impl TextOutline {129impl TextOutline {
127 /// A single plain paragraph containing only ASCII spaces or no text.130 /// A single plain paragraph containing only ASCII spaces or no text.
128 pub fn is_empty(&self) -> bool {131 pub fn is_empty(&self) -> bool {
129 self.document.nodes().len() == 1132 let [node] = self.document.nodes() else {
130 && self.document.validate_flat().is_ok()133 return false;
131 && self134 };
132 .document135 node.lists.is_empty()
133 .paragraphs()136 && node.tags.is_empty()
134 .next()137 && !node.collapsed
135 .unwrap()138 && node.text().is_some_and(|text| {
136 .text()139 text.tags.is_empty() && text.text.text().bytes().all(|byte| byte == b' ')
137 .bytes()140 })
138 .all(|byte| byte == b' ')
139 }141 }
140142
141 /// Tests an outline-local point against the provisional paragraph band.143 /// Tests an outline-local point against the provisional paragraph band.
142 pub fn contains_extension(&self, point: [f32; 2]) -> bool {144 pub fn contains_extension(&self, point: [f32; 2]) -> bool {
143 !self.title145 !self.title
144 && self.document.validate_flat().is_ok()
145 && (0.0..=self.shaped.size[0]).contains(&point[0])146 && (0.0..=self.shaped.size[0]).contains(&point[0])
146 && point[1] > self.shaped.size[1]147 && point[1] > self.shaped.size[1]
147 && point[1] <= self.shaped.size[1] + 27.0148 && point[1] <= self.shaped.size[1] + 27.0
...@@ -1148,7 +1149,11 @@ impl CanvasEditor {...@@ -1148,7 +1149,11 @@ impl CanvasEditor {
1148 let outline = self.active_outline();1149 let outline = self.active_outline();
1149 let count = outline.document.nodes().len();1150 let count = outline.document.nodes().len();
1150 let height = outline.shaped.size[1];1151 let height = outline.shaped.size[1];
1151 let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?;1152 let last = &outline.document.nodes()[count - 1];
1153 let node = PageParagraph {
1154 parent: last.parent,
1155 ..self.blank_paragraph(last)?
1156 };
1152 self.apply(1157 self.apply(
1153 engine,1158 engine,
1154 DocumentEdit {1159 DocumentEdit {
...@@ -1864,13 +1869,6 @@ impl CanvasEditor {...@@ -1864,13 +1869,6 @@ impl CanvasEditor {
1864 };1869 };
1865 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {1870 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {
1866 let outline = self.active_outline();1871 let outline = self.active_outline();
1867 crate::document::validate_flat(
1868 outline
1869 .document
1870 .nodes()
1871 .iter()
1872 .filter(|node| node.text().is_some()),
1873 )?;
1874 let last = outline.document.nodes().len() - 1;1872 let last = outline.document.nodes().len() - 1;
1875 let up = matches!(movement, Movement::Up);1873 let up = matches!(movement, Movement::Up);
1876 let base_node = &outline.document.nodes()[if up { 0 } else { last }];1874 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
...@@ -1964,7 +1962,10 @@ impl CanvasEditor {...@@ -1964,7 +1962,10 @@ impl CanvasEditor {
1964 columns: BTreeMap::new(),1962 columns: BTreeMap::new(),
1965 container: None,1963 container: None,
1966 range: last + 1..last + 1,1964 range: last + 1..last + 1,
1967 replacement: vec![node],1965 replacement: vec![PageParagraph {
1966 parent: base_node.parent,
1967 ..node
1968 }],
1968 };1969 };
1969 let paragraph = outline.document.paragraphs().count();1970 let paragraph = outline.document.paragraphs().count();
1970 let previous = if let Focus::Outline(index) = self.active {1971 let previous = if let Focus::Outline(index) = self.active {
...@@ -2223,12 +2224,127 @@ impl CanvasEditor {...@@ -2223,12 +2224,127 @@ impl CanvasEditor {
2223 self.replace(engine, replacement)2224 self.replace(engine, replacement)
2224 }2225 }
22252226
2227 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
2228 /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`).
2229 pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
2230 let lines = text
2231 .split('\n')
2232 .map(|line| line.strip_suffix('\r').unwrap_or(line))
2233 .collect::<Vec<_>>();
2234 let last = lines[lines.len() - 1];
2235 if lines.len() == 1 {
2236 return self.insert(engine, last);
2237 }
2238 let [anchor, focus] = self.active_outline().selection.positions;
2239 let (start, end) = (anchor.min(focus), anchor.max(focus));
2240 let edge = Paragraph::new(String::new(), self.typing_format(start)?);
2241 let pasted = Format {
2242 font: Some("Calibri".into()),
2243 font_size: Some(11.0),
2244 ..Format::default()
2245 };
2246 let mut edit = self.active_outline().document.replace(
2247 start..end,
2248 std::iter::once(edge.clone())
2249 .chain(
2250 lines
2251 .iter()
2252 .map(|line| Paragraph::new((*line).to_owned(), pasted.clone())),
2253 )
2254 .chain([edge])
2255 .collect(),
2256 )?;
2257 for node in &mut edit.replacement[1..=lines.len()] {
2258 node.style = None;
2259 node.lists.clear();
2260 }
2261 let caret = TextPosition {
2262 paragraph: start.paragraph + lines.len(),
2263 offset: u32::try_from(last.encode_utf16().count())
2264 .map_err(|_| EditError::TextTooLong)?,
2265 };
2266 self.commit(
2267 engine,
2268 edit,
2269 Selection {
2270 positions: [caret; 2],
2271 affinities: [Affinity::Upstream; 2],
2272 },
2273 )
2274 }
2275
2276 /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the
2277 /// level, lists and character formatting at the caret but no note tags. At a paragraph's
2278 /// start its tags stay with its text below; an empty paragraph opens a plain one above and
2279 /// leaves the list it ends; a heading continues as body text.
2280 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
2281 let [anchor, focus] = self.active_outline().selection.positions;
2282 let (start, end) = (anchor.min(focus), anchor.max(focus));
2283 let format = self.typing_format(start)?;
2284 let document = &self.active_outline().document;
2285 let mut edit =
2286 document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?;
2287 let nodes = document.container(edit.container)?;
2288 let next = nodes
2289 .get(crate::document::subtree_end(nodes, edit.range.start))
2290 .filter(|next| {
2291 let node = &nodes[edit.range.start];
2292 next.parent == node.parent && next.level == node.level
2293 });
2294 let [head, tail, ..] = &mut edit.replacement[..] else {
2295 unreachable!("a split holds both halves")
2296 };
2297 let empty = |node: &PageParagraph| node.text().unwrap().text.text().is_empty();
2298 if start.offset == 0 {
2299 tail.tags = std::mem::take(&mut head.tags);
2300 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
2301 }
2302 let style = |id: Option<ExGuid>| match &self.definitions.get(&id?)?.kind {
2303 Kind::Style { name } => name.as_deref(),
2304 _ => None,
2305 };
2306 if empty(head) && empty(tail) {
2307 head.lists.clear();
2308 if next.is_none_or(|next| next.lists.is_empty()) {
2309 tail.lists.clear();
2310 }
2311 } else if empty(tail)
2312 && matches!(
2313 style(head.style),
2314 Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6")
2315 )
2316 {
2317 tail.style = self
2318 .definitions
2319 .keys()
2320 .copied()
2321 .find(|id| style(Some(*id)) == Some("p"));
2322 tail.text_mut().unwrap().text =
2323 Paragraph::new(String::new(), self.style_format(tail.style)?);
2324 }
2325 let caret = TextPosition {
2326 paragraph: start.paragraph + 1,
2327 offset: 0,
2328 };
2329 self.commit(
2330 engine,
2331 edit,
2332 Selection {
2333 positions: [caret; 2],
2334 affinities: [Affinity::Upstream; 2],
2335 },
2336 )
2337 }
2338
2339 /// Tab or Shift+Tab at the start of the selected paragraphs; see [`crate::document::indent`].
2340 /// A default bullet or number steps to the style OneNote gives its new depth.
2226 pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result<bool, EditorError> {2341 pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result<bool, EditorError> {
2227 let outline = self.active_outline();2342 let outline = self.active_outline();
2228 if outline.title {2343 if outline.title {
2229 return Ok(false);2344 return Ok(false);
2230 }2345 }
2231 let [anchor, focus] = outline.selection.positions;2346 let selection = outline.selection;
2347 let [anchor, focus] = selection.positions;
2232 let start = anchor.min(focus);2348 let start = anchor.min(focus);
2233 let end = anchor.max(focus);2349 let end = anchor.max(focus);
2234 let (container, local_start, _) = outline2350 let (container, local_start, _) = outline
...@@ -2245,96 +2361,111 @@ impl CanvasEditor {...@@ -2245,96 +2361,111 @@ impl CanvasEditor {
2245 let range = local_start2361 let range = local_start
2246 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);2362 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);
2247 let nodes = outline.document.container(container)?;2363 let nodes = outline.document.container(container)?;
2248 // Lists and tags move with their paragraphs; parent links and collapsed children would not.2364 let Some(mut edit) = crate::document::indent(nodes, container, range, outdent) else {
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 }
2255 let nodes = &nodes[range.clone()];
2256 if outdent && nodes.iter().all(|node| node.level == 1) {
2257 return Ok(false);2365 return Ok(false);
2258 }2366 };
2259 let replacement = nodes2367 let levels = nodes[edit.range.clone()]
2260 .iter()2368 .iter()
2261 .map(|node| {2369 .map(|node| node.level)
2262 let mut node = node.clone();2370 .collect::<Vec<_>>();
2263 node.level = if outdent {2371 for (node, level) in edit.replacement.iter_mut().zip(levels) {
2264 node.level.saturating_sub(1).max(1)2372 if node.level == level {
2265 } else {2373 continue;
2266 node.level2374 }
2267 .checked_add(1)2375 for list in &mut node.lists {
2268 .ok_or(EditError::InvalidStructure)?2376 let Some(definition) = self
2377 .definitions
2378 .get(list)
2379 .and_then(|definition| format::nested_list(definition, node.level > level))
2380 else {
2381 continue;
2269 };2382 };
2270 Ok(node)2383 *list = onestore::page::text::new_id()?;
2271 })2384 self.definitions.insert(*list, definition);
2272 .collect::<Result<Vec<_>, EditError>>()?;2385 }
2273 self.commit(2386 }
2274 engine,2387 self.commit(engine, edit, selection)?;
2275 DocumentEdit {
2276 columns: BTreeMap::new(),
2277 container,
2278 range,
2279 replacement,
2280 },
2281 outline.selection,
2282 )?;
2283 Ok(true)2388 Ok(true)
2284 }2389 }
22852390
2391 /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start
2392 /// first removes its list, then outdents it, then joins it to the paragraph above, whose
2393 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
2394 /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children.
2286 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {2395 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {
2287 let [anchor, focus] = self.active_outline().selection.positions;2396 let selection = self.active_outline().selection;
2397 let [anchor, focus] = selection.positions;
2288 let mut range = anchor.min(focus)..anchor.max(focus);2398 let mut range = anchor.min(focus)..anchor.max(focus);
2289 if range.is_empty() {2399 if range.is_empty() {
2290 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;2400 let outline = self.active_outline();
2291 let cursor =2401 let paragraph = outline.paragraph_layout(focus.paragraph)?;
2292 paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?;2402 let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?;
2293 if let Some(cluster) =2403 let Some(cluster) =
2294 cursor.logical_clusters(&paragraph.text.shaped)[usize::from(!backward)]2404 cursor.logical_clusters(&paragraph.text.shaped)[usize::from(!backward)]
2295 {2405 else {
2296 let visible = paragraph.projection.text();2406 let (container, local, node) = outline
2297 let bytes = cluster.text_range();2407 .document
2298 range.start.offset = paragraph.projection.source_offset(2408 .leaf(focus.paragraph)
2299 visible.utf16_offset(bytes.start)?,2409 .ok_or(EditError::InvalidRange)?;
2300 onestore::page::text::Affinity::Downstream,2410 if backward && !node.lists.is_empty() {
2301 )?;2411 let item = PageParagraph {
2302 range.end.offset = paragraph.projection.source_offset(2412 lists: Vec::new(),
2303 visible.utf16_offset(bytes.end)?,2413 ..node.clone()
2304 onestore::page::text::Affinity::Upstream,2414 };
2305 )?;2415 let edit = DocumentEdit {
2306 } else {2416 columns: BTreeMap::new(),
2307 let document = &self.active_outline().document;2417 container,
2418 range: local..local + 1,
2419 replacement: vec![item],
2420 };
2421 self.commit(engine, edit, selection)?;
2422 return Ok(true);
2423 }
2424 if backward && node.level > 1 {
2425 return self.indent(engine, true);
2426 }
2427 let visible = outline.visible_index(focus.paragraph)?;
2308 let Some(neighbor) = (if backward {2428 let Some(neighbor) = (if backward {
2309 focus.paragraph.checked_sub(1)2429 visible.checked_sub(1)
2310 } else {2430 } else {
2311 focus.paragraph.checked_add(1)2431 Some(visible + 1).filter(|next| *next < outline.shaped.paragraphs.len())
2312 }) else {2432 }) else {
2313 return Ok(false);2433 return Ok(false);
2314 };2434 };
2315 let (container, local, current) = document2435 let neighbor = outline.source_index(neighbor);
2316 .leaf(focus.paragraph)2436 let [upper, lower] = if backward {
2317 .ok_or(EditError::InvalidRange)?;2437 [neighbor, focus.paragraph]
2318 let Some((next_container, next_local, next)) = document.leaf(neighbor) else {2438 } else {
2319 return Ok(false);2439 [focus.paragraph, neighbor]
2320 };2440 };
2321 if container != next_container || local.abs_diff(next_local) != 1 {2441 let Some(edit) = outline.document.join(upper, lower)? else {
2322 return Ok(false);2442 return Ok(false);
2323 }
2324 let (previous, start, end) = if backward {
2325 (next, neighbor, focus.paragraph)
2326 } else {
2327 (current, focus.paragraph, neighbor)
2328 };2443 };
2329 let text = &previous.text().unwrap().text;2444 let text = outline.document.paragraph(upper).unwrap();
2330 range = TextPosition {2445 let caret = TextPosition {
2331 paragraph: start,2446 paragraph: upper,
2332 offset: text.utf16_offset(text.text().len())?,2447 offset: text.utf16_offset(text.text().len())?,
2333 }..TextPosition {
2334 paragraph: end,
2335 offset: 0,
2336 };2448 };
2337 }2449 self.commit(
2450 engine,
2451 edit,
2452 Selection {
2453 positions: [caret; 2],
2454 affinities: [Affinity::Upstream; 2],
2455 },
2456 )?;
2457 return Ok(true);
2458 };
2459 let visible = paragraph.projection.text();
2460 let bytes = cluster.text_range();
2461 range.start.offset = paragraph.projection.source_offset(
2462 visible.utf16_offset(bytes.start)?,
2463 onestore::page::text::Affinity::Downstream,
2464 )?;
2465 range.end.offset = paragraph.projection.source_offset(
2466 visible.utf16_offset(bytes.end)?,
2467 onestore::page::text::Affinity::Upstream,
2468 )?;
2338 }2469 }
2339 self.delete_range(engine, range)2470 self.delete_range(engine, range)
2340 }2471 }
...@@ -2748,10 +2879,11 @@ impl CanvasEditor {...@@ -2748,10 +2879,11 @@ impl CanvasEditor {
2748 .split('\n')2879 .split('\n')
2749 .map(|part| Paragraph::new(part.to_owned(), format.clone()))2880 .map(|part| Paragraph::new(part.to_owned(), format.clone()))
2750 .collect();2881 .collect();
2751 let edit = self2882 let mut edit = self
2752 .active_outline()2883 .active_outline()
2753 .document2884 .document
2754 .replace(range.clone(), replacement)?;2885 .replace(range.clone(), replacement)?;
2886 self.own_lists(&mut edit)?;
2755 let inverse = self.apply(2887 let inverse = self.apply(
2756 engine,2888 engine,
2757 edit,2889 edit,
...@@ -2764,6 +2896,12 @@ impl CanvasEditor {...@@ -2764,6 +2896,12 @@ impl CanvasEditor {
2764 )?;2896 )?;
2765 let original = if let Some(composition) = self.composition.take() {2897 let original = if let Some(composition) = self.composition.take() {
2766 let mut original = composition.original;2898 let mut original = composition.original;
2899 // Nodes this edit reaches past what the composition changed are still as they were.
2900 let changed = original.edit.range.len();
2901 original
2902 .edit
2903 .replacement
2904 .extend(inverse.edit.replacement.iter().skip(changed).cloned());
2767 original.edit.range = inverse.edit.range;2905 original.edit.range = inverse.edit.range;
2768 for (id, widths) in inverse.edit.columns {2906 for (id, widths) in inverse.edit.columns {
2769 original.edit.columns.entry(id).or_insert(widths);2907 original.edit.columns.entry(id).or_insert(widths);
...@@ -2832,14 +2970,40 @@ impl CanvasEditor {...@@ -2832,14 +2970,40 @@ impl CanvasEditor {
2832 fn commit(2970 fn commit(
2833 &mut self,2971 &mut self,
2834 engine: &mut TextEngine,2972 engine: &mut TextEngine,
2835 edit: DocumentEdit,2973 mut edit: DocumentEdit,
2836 selection: Selection,2974 selection: Selection,
2837 ) -> Result<(), EditorError> {2975 ) -> Result<(), EditorError> {
2976 self.own_lists(&mut edit)?;
2838 let inverse = self.apply(engine, edit, selection, true, None)?;2977 let inverse = self.apply(engine, edit, selection, true, None)?;
2839 self.record_change(inverse);2978 self.record_change(inverse);
2840 Ok(())2979 Ok(())
2841 }2980 }
28422981
2982 /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps
2983 /// a list node per paragraph.
2984 fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> {
2985 let existing = self.active_outline().document.container(edit.container)?
2986 [edit.range.clone()]
2987 .iter()
2988 .map(|node| node.id)
2989 .collect::<BTreeSet<_>>();
2990 for node in &mut edit.replacement {
2991 if existing.contains(&node.id) {
2992 continue;
2993 }
2994 for list in &mut node.lists {
2995 let definition = self
2996 .definitions
2997 .get(list)
2998 .ok_or(EditError::InvalidStructure)?
2999 .clone();
3000 *list = onestore::page::text::new_id()?;
3001 self.definitions.insert(*list, definition);
3002 }
3003 }
3004 Ok(())
3005 }
3006
2843 fn record_change(&mut self, change: TextChange) {3007 fn record_change(&mut self, change: TextChange) {
2844 self.pending = None;3008 self.pending = None;
2845 if let Focus::Draft { index, .. } = self.active {3009 if let Focus::Draft { index, .. } = self.active {
...@@ -5553,6 +5717,9 @@ mod tests {...@@ -5553,6 +5717,9 @@ mod tests {
5553 .collect::<Vec<_>>(),5717 .collect::<Vec<_>>(),
5554 [2, 2, 1]5718 [2, 2, 1]
5555 );5719 );
5720 // Backspace outdents an indented paragraph before joining it (`c4-child-1.xml`).
5721 editor.delete(&mut engine, true).unwrap();
5722 assert_eq!(editor.active_outline().document.nodes()[1].level, 1);
5556 editor.delete(&mut engine, true).unwrap();5723 editor.delete(&mut engine, true).unwrap();
5557 assert_eq!(5724 assert_eq!(
5558 editor.active_outline().document.nodes()[0]5725 editor.active_outline().document.nodes()[0]
...@@ -5562,9 +5729,9 @@ mod tests {...@@ -5562,9 +5729,9 @@ mod tests {
5562 .text(),5729 .text(),
5563 "alpha beta gamma delta"5730 "alpha beta gamma delta"
5564 );5731 );
5565 editor.undo(&mut engine).unwrap();5732 for _ in 0..4 {
5566 editor.undo(&mut engine).unwrap();5733 editor.undo(&mut engine).unwrap();
5567 editor.undo(&mut engine).unwrap();5734 }
5568 assert_eq!(editor.active_outline().document, source);5735 assert_eq!(editor.active_outline().document, source);
5569 assert_eq!(editor.selection(), selection);5736 assert_eq!(editor.selection(), selection);
5570 assert!(!editor.indent(&mut engine, true).unwrap());5737 assert!(!editor.indent(&mut engine, true).unwrap());
...@@ -6505,7 +6672,16 @@ mod tests {...@@ -6505,7 +6672,16 @@ mod tests {
6505 let edited = layout_snapshot(&editor.active_outline().shaped);6672 let edited = layout_snapshot(&editor.active_outline().shaped);
6506 let before = editor.active_outline().document.clone();6673 let before = editor.active_outline().document.clone();
6507 let selection = editor.selection();6674 let selection = editor.selection();
6508 assert!(editor.insert(&mut engine, "\n").is_err());6675 editor.enter(&mut engine, false).unwrap();
6676 let split = &editor.active_outline().document.nodes()[2];
6677 assert_eq!((split.parent, split.level), (Some(nodes[0].id), 2));
6678 assert_ne!(split.lists, [marker]);
6679 assert_eq!(
6680 editor.definitions[&split.lists[0]],
6681 editor.definitions[&marker]
6682 );
6683 editor.undo(&mut engine).unwrap();
6684 assert_eq!(editor.active_outline().document, before);
6509 assert!(editor.resize(&mut engine, 20.0).is_err());6685 assert!(editor.resize(&mut engine, 20.0).is_err());
6510 assert_eq!(editor.active_outline().document, before);6686 assert_eq!(editor.active_outline().document, before);
6511 assert_eq!(editor.selection(), selection);6687 assert_eq!(editor.selection(), selection);
...@@ -6555,6 +6731,75 @@ mod tests {...@@ -6555,6 +6731,75 @@ mod tests {
6555 assert!(!editor.selection_rects().unwrap().is_empty());6731 assert!(!editor.selection_rects().unwrap().is_empty());
6556 }6732 }
65576733
6734 #[test]
6735 fn joins_pass_over_hidden_children_and_a_split_hands_them_on_still_collapsed() {
6736 let mut engine = TextEngine::default();
6737 let mut nodes = TextDocument::new(
6738 ["Collapsed", "Hidden", "After"]
6739 .into_iter()
6740 .map(|text| Paragraph::new(text.into(), Format::default()))
6741 .collect(),
6742 )
6743 .unwrap()
6744 .nodes()
6745 .to_vec();
6746 nodes[0].collapsed = true;
6747 nodes[1].parent = Some(nodes[0].id);
6748 nodes[1].level = 2;
6749 let outline = Outline {
6750 paragraphs: nodes.clone(),
6751 ..TextOutline::new(
6752 &mut engine,
6753 TextDocument::from_nodes(nodes.clone()).unwrap(),
6754 300.0,
6755 [0.0; 2],
6756 )
6757 .unwrap()
6758 .snapshot()
6759 };
6760 let mut editor =
6761 CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap();
6762 let texts = |editor: &CanvasEditor| {
6763 editor
6764 .active_outline()
6765 .document
6766 .paragraphs()
6767 .map(|text| text.text().to_owned())
6768 .collect::<Vec<_>>()
6769 };
6770 let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
6771 editor.select(at(2, 0)).unwrap();
6772 assert!(editor.delete(&mut engine, true).unwrap());
6773 assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]);
6774 assert_eq!(
6775 editor.selection().positions,
6776 [TextPosition {
6777 paragraph: 0,
6778 offset: 9
6779 }; 2]
6780 );
6781 editor.undo(&mut engine).unwrap();
6782 editor.select(at(0, 9)).unwrap();
6783 assert!(editor.delete(&mut engine, false).unwrap());
6784 assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]);
6785 editor.undo(&mut engine).unwrap();
6786 editor.select(at(0, 9)).unwrap();
6787 editor.enter(&mut engine, false).unwrap();
6788 let split = editor.active_outline().document.nodes();
6789 assert!(!split[0].collapsed && split[1].collapsed);
6790 assert_eq!(split[2].parent, Some(split[1].id));
6791 assert_eq!(
6792 editor
6793 .active_outline()
6794 .layouts()
6795 .map(|(index, _)| index)
6796 .collect::<Vec<_>>(),
6797 [0, 1, 3]
6798 );
6799 editor.undo(&mut engine).unwrap();
6800 assert_eq!(editor.active_outline().document.nodes(), nodes);
6801 }
6802
6558 #[test]6803 #[test]
6559 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]6804 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
6560 fn imported_editor_reflows_and_restores_native_outline_geometry() {6805 fn imported_editor_reflows_and_restores_native_outline_geometry() {
...@@ -7934,9 +8179,13 @@ mod tests {...@@ -7934,9 +8179,13 @@ mod tests {
7934 .outlines8179 .outlines
7935 .iter()8180 .iter()
7936 .find(|outline| {8181 .find(|outline| {
8182 let [node] = outline.document.nodes() else {
8183 return false;
8184 };
7937 !outline.title8185 !outline.title
7938 && outline.document.nodes().len() == 18186 && node.lists.is_empty()
7939 && outline.document.validate_flat().is_ok()8187 && node.tags.is_empty()
8188 && node.text().is_some_and(|text| text.tags.is_empty())
7940 })8189 })
7941 .unwrap()8190 .unwrap()
7942 .id;8191 .id;
crates/canvas/src/editor/evidence.rs created+765
...@@ -0,0 +1,765 @@
1//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it
2//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and
3//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts
4//! paragraph nesting.
5
6use super::format::{NoteTag, list_definition};
7use super::*;
8use onestore::document::Tag;
9use onestore::page::ParagraphContent;
10use onestore::page::text::new_id;
11
12fn summary(name: &str) -> String {
13 std::fs::read_to_string(format!(
14 "{}/../../evidence/structural-edits/summaries/{name}.txt",
15 env!("CARGO_MANIFEST_DIR")
16 ))
17 .unwrap()
18 .replace("\r\n", "\n")
19}
20
21fn calibri(size: f32, bold: bool) -> Format {
22 Format {
23 font: Some("Calibri".into()),
24 font_size: Some(size),
25 bold: Some(bold),
26 ..Format::default()
27 }
28}
29
30/// The quick styles a summary's `qs0` and `qs1` name.
31const STYLES: [&str; 2] = ["p", "h1"];
32
33/// An editor holding one outline shaped as a summary describes it.
34fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
35 let mut definitions = BTreeMap::new();
36 let mut ids = BTreeMap::new();
37 let mut define = |key: String, definition: Definition| {
38 *ids.entry(key).or_insert_with(|| {
39 let id = new_id().unwrap();
40 definitions.insert(id, definition);
41 id
42 })
43 };
44 let styles = STYLES.map(|name| {
45 define(
46 name.into(),
47 Definition {
48 kind: Kind::Style {
49 name: Some(name.into()),
50 },
51 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),
52 },
53 )
54 });
55 let mut nodes: Vec<PageParagraph> = Vec::new();
56 for line in summary.lines() {
57 let line = line.trim_start().strip_prefix('L').unwrap();
58 let (level, line) = line.split_once(" [").unwrap();
59 let level = level.parse::<u32>().unwrap();
60 let (tags, line) = line.split_once("] [").unwrap();
61 let (list, line) = line.split_once("] ").unwrap();
62 let (style, text) = line.split_once(' ').unwrap();
63 let style = STYLES
64 .iter()
65 .position(|name| style == format!("qs{}", usize::from(*name == "h1")))
66 .map(|index| styles[index]);
67 let format = calibri(11.0, false);
68 let mut node =
69 crate::document::node(Paragraph::new(text.into(), format.clone()), format.clone())
70 .unwrap();
71 node.level = level;
72 node.style = style;
73 node.parent = nodes
74 .iter()
75 .rev()
76 .find(|parent| parent.level + 1 == level)
77 .map(|parent| parent.id);
78 if !list.is_empty() {
79 let definition = list_definition(list.starts_with("num"), &format);
80 node.lists = vec![define(format!("list {}", nodes.len()), definition)];
81 }
82 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
83 let (label, completed) = match tag.strip_suffix("[x]") {
84 Some(label) => (label, true),
85 None => (tag, false),
86 };
87 let tag = NoteTag::ALL
88 .into_iter()
89 .find(|tag| {
90 matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label)
91 })
92 .unwrap();
93 let definition = define(label.into(), tag.definition());
94 node.text_mut().unwrap().tags.push(Tag {
95 definition: Some(definition),
96 action_type: None,
97 status: u16::from(completed),
98 created: Some(1),
99 completed: Some(u32::from(completed)),
100 start: None,
101 due: None,
102 task_id: None,
103 extra_set: 0,
104 });
105 }
106 nodes.push(node);
107 }
108 let outline = Outline {
109 id: new_id().unwrap(),
110 title: false,
111 min_width: None,
112 layout: onestore::document::Layout {
113 max_width: Some(400.0),
114 ..Default::default()
115 },
116 indents: vec![18.0, 0.0, 27.0, 27.0],
117 paragraphs: nodes,
118 unsupported: Vec::new(),
119 };
120 CanvasEditor::from_outlines(engine, vec![outline], definitions).unwrap()
121}
122
123/// The outline in the summaries' form.
124fn render(editor: &CanvasEditor) -> String {
125 let outline = editor.active_outline();
126 let mut depths = BTreeMap::new();
127 let mut lines = Vec::new();
128 for node in outline.document.nodes() {
129 let depth = node.parent.map_or(1, |parent| depths[&parent] + 1);
130 depths.insert(node.id, depth);
131 let tags = node
132 .tags
133 .iter()
134 .chain(&node.text().unwrap().tags)
135 .map(|tag| {
136 let kind = &editor.definitions[&tag.definition.unwrap()].kind;
137 let Kind::TagDefinition { label, .. } = kind else {
138 panic!("{kind:?}")
139 };
140 let checked = if tag.status & 1 == 1 { "[x]" } else { "" };
141 (
142 NoteTag::ALL
143 .iter()
144 .position(|known| known.definition().kind == *kind),
145 format!("{}{checked}", label.as_deref().unwrap()),
146 )
147 })
148 .collect::<BTreeMap<_, _>>();
149 let list = match node.lists.first().map(|id| &editor.definitions[id].kind) {
150 None => String::new(),
151 Some(Kind::List {
152 bullet: Some(_), ..
153 }) => "bullet".into(),
154 Some(Kind::List {
155 format: Some(format),
156 ..
157 }) => {
158 let (_, rest) = format.split_once('\u{fffd}').unwrap();
159 let mut rest = rest.chars();
160 let number = outline
161 .shaped
162 .paragraphs
163 .iter()
164 .find(|paragraph| paragraph.id == node.id)
165 .and_then(|paragraph| paragraph.number)
166 .unwrap()
167 .0;
168 let numeral = crate::outline::numeral(rest.next(), number).unwrap();
169 format!("num:{numeral}{}", rest.as_str())
170 }
171 Some(kind) => panic!("{kind:?}"),
172 };
173 let style = node
174 .style
175 .map(|style| match &editor.definitions[&style].kind {
176 Kind::Style { name } => {
177 format!("qs{}", usize::from(name.as_deref() == Some("h1")))
178 }
179 kind => panic!("{kind:?}"),
180 })
181 .unwrap_or_default();
182 lines.push(format!(
183 "{}L{depth} [{}] [{list}] {style} {}",
184 " ".repeat(depth as usize),
185 tags.into_values().collect::<Vec<_>>().join(","),
186 node.text().unwrap().text.text()
187 ));
188 }
189 lines.join("\n").trim_end().to_owned()
190}
191
192#[derive(Clone, Copy)]
193enum Offset {
194 Start,
195 End,
196 /// Characters before the end.
197 Back(u32),
198 /// Characters after the start.
199 Forward(u32),
200}
201
202#[derive(Clone, Copy)]
203enum Key {
204 Enter,
205 Backspace,
206 Delete,
207 Tab,
208 ShiftTab,
209 Type(&'static str),
210 Paste(&'static str),
211 Bullets,
212}
213
214/// Puts the caret at `offset` in paragraph `paragraph`.
215fn place(editor: &mut CanvasEditor, (paragraph, offset): (usize, Offset)) {
216 let text = editor
217 .active_outline()
218 .document
219 .paragraph(paragraph)
220 .unwrap();
221 let length = text.text().encode_utf16().count() as u32;
222 let offset = match offset {
223 Offset::Start => 0,
224 Offset::End => length,
225 Offset::Back(count) => length - count,
226 Offset::Forward(count) => count,
227 };
228 editor
229 .select([TextPosition { paragraph, offset }; 2].into())
230 .unwrap();
231}
232
233fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) {
234 match key {
235 Key::Enter => editor.enter(engine, false).unwrap(),
236 Key::Backspace => assert!(editor.delete(engine, true).unwrap()),
237 Key::Delete => assert!(editor.delete(engine, false).unwrap()),
238 Key::Tab => editor.tab(engine, false).unwrap(),
239 Key::ShiftTab => editor.tab(engine, true).unwrap(),
240 Key::Type(text) => editor.insert(engine, text).unwrap(),
241 Key::Paste(text) => editor.paste(engine, text).unwrap(),
242 Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(),
243 }
244}
245
246/// Replays `steps` from `caret` in `before`, comparing the outline after each key with the
247/// summary it names; returns the editor for further checks.
248fn replay(
249 engine: &mut TextEngine,
250 before: &str,
251 caret: (usize, Offset),
252 steps: &[(Key, &str)],
253) -> CanvasEditor {
254 let mut editor = open(engine, before);
255 place(&mut editor, caret);
256 let original = editor.active_outline().document.clone();
257 for (key, after) in steps {
258 press(&mut editor, engine, *key);
259 assert_eq!(render(&editor), summary(after).trim_end(), "{after}");
260 let outline = editor.active_outline();
261 let fresh = OutlineLayout::flow(
262 outline.document.nodes().iter(),
263 &outline.indents,
264 outline.wrap_width(),
265 true,
266 0,
267 None,
268 &mut |node, previous, width, indents| {
269 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
270 },
271 )
272 .unwrap();
273 let numbers = |layout: &OutlineLayout| {
274 layout
275 .paragraphs
276 .iter()
277 .map(|paragraph| paragraph.number)
278 .collect::<Vec<_>>()
279 };
280 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
281 }
282 let edited = editor.active_outline().document.clone();
283 while editor.undo(engine).unwrap() {}
284 assert_eq!(editor.active_outline().document, original);
285 while editor.redo(engine).unwrap() {}
286 assert_eq!(editor.active_outline().document, edited);
287 editor
288}
289
290/// The run that recorded Enter at the end of a paragraph of `kind`.
291fn first_run(kind: &str) -> &'static str {
292 match kind {
293 "num" | "child" | "tagbullet" => "c1b",
294 _ => "c1",
295 }
296}
297
298/// The setup every single-paragraph case starts from: `Above`, then `Target text` of `kind`.
299fn target(kind: &str) -> String {
300 summary(&format!("{}-{kind}-0", first_run(kind)))
301}
302
303const KINDS: [&str; 9] = [
304 "todo",
305 "done",
306 "imp",
307 "q",
308 "rem",
309 "bullet",
310 "num",
311 "child",
312 "tagbullet",
313];
314
315#[test]
316fn enter_carries_level_lists_and_children_but_never_tags() {
317 let mut engine = TextEngine::default();
318 for kind in KINDS {
319 let prefix = first_run(kind);
320 replay(
321 &mut engine,
322 &target(kind),
323 (1, Offset::End),
324 &[
325 (Key::Enter, &format!("{prefix}-{kind}-1")),
326 (Key::Type("New"), &format!("{prefix}-{kind}-2")),
327 ],
328 );
329 replay(
330 &mut engine,
331 &target(kind),
332 (1, Offset::Back(4)),
333 &[
334 (Key::Enter, &format!("c2-{kind}-1")),
335 (Key::Type("X"), &format!("c2-{kind}-2")),
336 ],
337 );
338 replay(
339 &mut engine,
340 &target(kind),
341 (1, Offset::Start),
342 &[
343 (Key::Enter, &format!("c2s-{kind}-1")),
344 (Key::Type("X"), &format!("c2s-{kind}-2")),
345 ],
346 );
347 replay(
348 &mut engine,
349 &summary(&format!("c3-{kind}-0")),
350 (2, Offset::Start),
351 &[
352 (Key::Enter, &format!("c3-{kind}-1")),
353 (Key::Enter, &format!("c3-{kind}-2")),
354 (Key::Type("New"), &format!("c3-{kind}-3")),
355 ],
356 );
357 }
358 replay(
359 &mut engine,
360 &summary("pbe-0"),
361 (2, Offset::Start),
362 &[(Key::Enter, "pbe-1")],
363 );
364 replay(
365 &mut engine,
366 &summary("c8-enter-empty-0"),
367 (1, Offset::End),
368 &[
369 (Key::Enter, "c8-enter-empty-1"),
370 (Key::Enter, "c8-enter-empty-2"),
371 ],
372 );
373 replay(
374 &mut engine,
375 &summary("c8-split-0"),
376 (2, Offset::Forward(1)),
377 &[(Key::Enter, "c8-split-1")],
378 );
379 replay(
380 &mut engine,
381 &summary("c10-enter-end-0"),
382 (1, Offset::End),
383 &[(Key::Enter, "c10-enter-end-1")],
384 );
385}
386
387#[test]
388fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {
389 let mut engine = TextEngine::default();
390 for kind in KINDS {
391 let steps = ["-1", "-2", "-3"].map(|step| format!("c4-{kind}{step}"));
392 replay(
393 &mut engine,
394 &summary(&format!("c4-{kind}-0")),
395 (2, Offset::Start),
396 &steps.each_ref().map(|step| (Key::Backspace, step.as_str())),
397 );
398 }
399 for pair in [
400 "imp-todo",
401 "plain-todo",
402 "todo-plain",
403 "todo-done",
404 "done-todo",
405 "q-rem",
406 ] {
407 replay(
408 &mut engine,
409 &summary(&format!("c4m-{pair}-0")),
410 (1, Offset::Start),
411 &[(Key::Backspace, &format!("c4m-{pair}-1"))],
412 );
413 }
414 replay(
415 &mut engine,
416 &summary("c4m-emptybullet-0"),
417 (2, Offset::Start),
418 &[
419 (Key::Backspace, "c4m-emptybullet-1"),
420 (Key::Backspace, "c4m-emptybullet-2"),
421 (Key::Backspace, "c4m-emptybullet-3"),
422 ],
423 );
424 replay(
425 &mut engine,
426 &summary("c4m-emptytag-0"),
427 (1, Offset::Start),
428 &[
429 (Key::Backspace, "c4m-emptytag-1"),
430 (Key::Backspace, "c4m-emptytag-2"),
431 ],
432 );
433 replay(
434 &mut engine,
435 &summary("c8-bs-0"),
436 (2, Offset::Start),
437 &[(Key::Backspace, "c8-bs-1"), (Key::Backspace, "c8-bs-2")],
438 );
439 replay(
440 &mut engine,
441 &summary("c10-bs-parent-0"),
442 (1, Offset::Start),
443 &[(Key::Backspace, "c10-bs-parent-1")],
444 );
445}
446
447#[test]
448fn delete_at_the_end_joins_the_next_paragraph_into_this_one() {
449 let mut engine = TextEngine::default();
450 for (pair, paragraph) in [
451 ("imp-todo", 0),
452 ("plain-todo", 0),
453 ("todo-plain", 0),
454 ("done-todo", 0),
455 ("plain-bullet", 0),
456 ("plain-num", 0),
457 ("bullet-plain", 1),
458 ("bullet-bullet", 1),
459 ] {
460 replay(
461 &mut engine,
462 &summary(&format!("c5b-{pair}-0")),
463 (paragraph, Offset::End),
464 &[(Key::Delete, &format!("c5b-{pair}-1"))],
465 );
466 }
467 replay(
468 &mut engine,
469 &summary("c8-del-0"),
470 (1, Offset::End),
471 &[(Key::Delete, "c8-del-1")],
472 );
473 for (name, paragraph) in [("del-alpha", 0), ("del-parent", 1)] {
474 replay(
475 &mut engine,
476 &summary(&format!("c10-{name}-0")),
477 (paragraph, Offset::End),
478 &[(Key::Delete, &format!("c10-{name}-1"))],
479 );
480 }
481}
482
483/// The list definition of the outline's paragraph `index`.
484fn list(editor: &CanvasEditor, index: usize) -> &Kind<'static> {
485 let node = &editor.active_outline().document.nodes()[index];
486 &editor.definitions[&node.lists[0]].kind
487}
488
489#[test]
490fn tab_nests_under_the_previous_sibling_and_shift_tab_adopts_the_siblings_after() {
491 let mut engine = TextEngine::default();
492 for kind in ["plain", "todo", "bullet", "num", "child", "tagbullet"] {
493 let editor = replay(
494 &mut engine,
495 &summary(&format!("c6-{kind}-0")),
496 (1, Offset::Start),
497 &[(Key::Tab, &format!("c6-{kind}-tab"))],
498 );
499 // Without a previous sibling OneNote indents within the group (`<OEChildren indent="2">`
500 // in `c6-{bullet,num,child,tagbullet}-tab.xml`), stepping bullets to ○ and numbers to a.
501 let first_child = matches!(kind, "bullet" | "num" | "child" | "tagbullet");
502 let node = &editor.active_outline().document.nodes()[1];
503 assert_eq!(node.level, if first_child { 3 } else { 2 }, "{kind}");
504 if matches!(kind, "bullet" | "tagbullet") {
505 assert!(matches!(
506 list(&editor, 1),
507 Kind::List { format: Some(glyph), bullet: Some(4), .. } if glyph == "\u{25cb}"
508 ));
509 }
510 replay(
511 &mut engine,
512 &summary(&format!("c6-{kind}-0")),
513 (1, Offset::Start),
514 &[
515 (Key::Tab, &format!("c6-{kind}-tab")),
516 (Key::ShiftTab, &format!("c6-{kind}-stab1")),
517 (Key::ShiftTab, &format!("c6-{kind}-stab2")),
518 ],
519 );
520 }
521 let first = replay(
522 &mut engine,
523 " L1 [] [] qs0 Target text",
524 (0, Offset::Start),
525 &[(Key::Tab, "c6-first-tab")],
526 );
527 assert_eq!(
528 (
529 first.active_outline().document.nodes()[0].level,
530 first.active_outline().document.nodes()[0].parent
531 ),
532 (2, None)
533 );
534 replay(
535 &mut engine,
536 &summary("c8-stab-0"),
537 (2, Offset::Start),
538 &[(Key::ShiftTab, "c8-stab-1")],
539 );
540 replay(
541 &mut engine,
542 &summary("c8-tab-0"),
543 (2, Offset::Start),
544 &[(Key::Tab, "c8-tab-1"), (Key::ShiftTab, "c8-tab-2")],
545 );
546 replay(
547 &mut engine,
548 &summary("c10-stab-child-0"),
549 (2, Offset::Start),
550 &[(Key::ShiftTab, "c10-stab-child-1")],
551 );
552}
553
554#[test]
555fn pasted_lines_are_plain_paragraphs_between_the_halves() {
556 let mut engine = TextEngine::default();
557 for kind in ["todo", "done", "bullet", "num", "tagbullet", "child"] {
558 // The native run's clipboard held a stray backquote for the numbered case.
559 let text = if kind == "num" {
560 "Line one\r\nLine two`"
561 } else {
562 "Line one\r\nLine two"
563 };
564 let editor = replay(
565 &mut engine,
566 &target(kind),
567 (1, Offset::Back(4)),
568 &[(Key::Paste(text), &format!("c7-{kind}-1"))],
569 );
570 let document = &editor.active_outline().document;
571 let format = Format {
572 bold: None,
573 ..calibri(11.0, false)
574 };
575 assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format);
576 let last = document.paragraph(3).unwrap().text();
577 assert_eq!(
578 editor.selection().positions[1],
579 TextPosition {
580 paragraph: 3,
581 offset: last.len() as u32
582 }
583 );
584 }
585 replay(
586 &mut engine,
587 " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second",
588 (1, Offset::Back(1)),
589 &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")],
590 );
591}
592
593#[test]
594fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() {
595 let mut engine = TextEngine::default();
596 replay(
597 &mut engine,
598 &summary("pb-0"),
599 (1, Offset::Start),
600 &[(Key::Bullets, "pb-1"), (Key::Bullets, "pb-2")],
601 );
602}
603
604#[test]
605fn a_heading_continues_as_body_text_unless_split() {
606 let mut engine = TextEngine::default();
607 // `c9-h1-1.xml` and `c9-h1-2.xml`; their summaries also carry the heading's inline runs.
608 let mut editor = open(&mut engine, " L1 [] [] qs1 Heading");
609 let end = TextPosition {
610 paragraph: 0,
611 offset: 7,
612 };
613 editor.select([end; 2].into()).unwrap();
614 editor.enter(&mut engine, false).unwrap();
615 editor.insert(&mut engine, "body").unwrap();
616 assert_eq!(
617 render(&editor),
618 " L1 [] [] qs1 Heading\n L1 [] [] qs0 body"
619 );
620 let body = editor.active_outline().document.paragraph(1).unwrap();
621 assert_eq!(body.spans()[0].format, calibri(11.0, false));
622 let middle = TextPosition {
623 paragraph: 0,
624 offset: 4,
625 };
626 editor.select([middle; 2].into()).unwrap();
627 editor.enter(&mut engine, false).unwrap();
628 assert_eq!(
629 render(&editor),
630 " L1 [] [] qs1 Head\n L1 [] [] qs1 ing\n L1 [] [] qs0 body"
631 );
632}
633
634#[test]
635fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {
636 let mut engine = TextEngine::default();
637 // `c9-multitag-{1,2,3}.xml`.
638 let mut editor = open(
639 &mut engine,
640 " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two tags",
641 );
642 let at = |offset| TextPosition {
643 paragraph: 1,
644 offset,
645 };
646 editor.select([at(4); 2].into()).unwrap();
647 editor.enter(&mut engine, false).unwrap();
648 assert_eq!(render(&editor), summary("c9-multitag-1").trim_end());
649 editor.delete(&mut engine, true).unwrap();
650 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());
651 editor.select([at(0); 2].into()).unwrap();
652 editor.delete(&mut engine, true).unwrap();
653 assert_eq!(render(&editor), summary("c9-multitag-3").trim_end());
654}
655
656/// Nesting, adopted siblings, restyled and removed lists, an outdent, a tagged split and a join
657/// that hands on children, on pages OneNote wrote, read back from the page writer unchanged.
658#[test]
659fn structural_edits_on_native_pages_survive_the_page_writer() {
660 use onestore::{RevisionIndex, Store, document::Document};
661 let section = std::fs::read(concat!(
662 env!("CARGO_MANIFEST_DIR"),
663 "/../../evidence/structural-edits/probe-section/probe.one"
664 ))
665 .unwrap();
666 let page = |bytes: &[u8], title: &str| {
667 let store = Store::parse(bytes).unwrap();
668 let index = RevisionIndex::parse(&store).unwrap();
669 let document = Document::parse(&index).unwrap();
670 let (space, _) = document
671 .pages()
672 .unwrap()
673 .into_iter()
674 .find(|(space, _)| Page::from_space(&document, *space).unwrap().title == title)
675 .unwrap();
676 (space, Page::from_space(&document, space).unwrap())
677 };
678 // Tags keep their place in the element's property arena only once read.
679 let settled = |mut page: Page| {
680 for object in &mut page.objects {
681 if let PageObject::Outline(outline) = object {
682 for paragraph in &mut outline.paragraphs {
683 for tag in &mut paragraph.text_mut().unwrap().tags {
684 tag.extra_set = 0;
685 }
686 }
687 }
688 }
689 page
690 };
691 let mut engine = TextEngine::default();
692 for (title, keys) in [
693 (
694 "c8-tab",
695 &[
696 ((2, Offset::Start), Key::ShiftTab),
697 ((4, Offset::Start), Key::Tab),
698 ((3, Offset::Start), Key::Backspace),
699 ][..],
700 ),
701 (
702 "c4-tagbullet",
703 &[
704 ((1, Offset::Start), Key::Backspace),
705 ((1, Offset::Start), Key::Backspace),
706 ],
707 ),
708 ("c2-todo", &[((1, Offset::Forward(3)), Key::Enter)]),
709 (
710 "c10-stab-child",
711 &[
712 ((2, Offset::Start), Key::Tab),
713 ((3, Offset::Start), Key::Tab),
714 ((1, Offset::End), Key::Delete),
715 ],
716 ),
717 ] {
718 let (space, source) = page(&section, title);
719 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
720 let body = editor
721 .outlines()
722 .iter()
723 .find(|outline| !outline.title)
724 .unwrap()
725 .id;
726 editor.focus_outline(body).unwrap();
727 for (caret, key) in keys {
728 place(&mut editor, *caret);
729 press(&mut editor, &mut engine, *key);
730 }
731 let edited = settled(editor.page().unwrap());
732 let written = onestore::PreparedEdit::page(&section, space, &edited, "Author")
733 .unwrap_or_else(|error| panic!("{title}: {error}"));
734 let (_, reread) = page(written.as_bytes(), title);
735 assert_eq!(settled(reread).objects, edited.objects, "{title}");
736 }
737}
738
739#[test]
740fn tab_inside_text_makes_a_table_whose_paragraph_keeps_the_list_tags_and_children() {
741 let mut engine = TextEngine::default();
742 // `c6-tagbullet-midtab.xml`: the table's paragraph holds the tag and bullet, its cells none.
743 let mut editor = open(&mut engine, &summary("c6-tagbullet-stab2"));
744 place(&mut editor, (1, Offset::Back(4)));
745 press(&mut editor, &mut engine, Key::Tab);
746 let nodes = editor.active_outline().document.nodes();
747 let ParagraphContent::Table(table) = &nodes[1].content else {
748 panic!("{:?}", nodes[1].content)
749 };
750 assert_eq!(
751 (nodes[1].level, nodes[1].lists.len(), nodes[1].tags.len()),
752 (1, 1, 1)
753 );
754 for cell in table.rows.iter().flat_map(|row| &row.cells) {
755 let text = cell.paragraphs[0].text().unwrap();
756 assert!(cell.paragraphs[0].lists.is_empty() && text.tags.is_empty());
757 }
758 // `c10-tab-parent-1.xml`: the children stay under the table's paragraph.
759 let mut editor = open(&mut engine, &summary("c10-tab-parent-0"));
760 place(&mut editor, (1, Offset::End));
761 press(&mut editor, &mut engine, Key::Tab);
762 let nodes = editor.active_outline().document.nodes();
763 assert!(matches!(nodes[1].content, ParagraphContent::Table(_)));
764 assert_eq!((nodes[2].parent, nodes[2].level), (Some(nodes[1].id), 2));
765}
crates/canvas/src/editor/format.rs+166-15
...@@ -52,13 +52,19 @@ pub enum Alignment {...@@ -52,13 +52,19 @@ pub enum Alignment {
52 Right,52 Right,
53}53}
5454
55/// OneNote 2010's default tags whose stored definition local evidence shows: To Do from a55/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions
56/// native Ctrl+1 (`corpus/paragraph-edit/reconciliation/keyboard`), Question from notebooks56/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types.
57/// OneNote tagged (`corpus/private/exact-native`).
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum NoteTag {58pub enum NoteTag {
60 ToDo,59 ToDo,
60 Important,
61 Question,61 Question,
62 RememberForLater,
63 Definition,
64 Highlight,
65 Contact,
66 Address,
67 PhoneNumber,
62}68}
6369
64/// What the selection shows on the toolbar.70/// What the selection shows on the toolbar.
...@@ -112,20 +118,38 @@ impl Toggle {...@@ -112,20 +118,38 @@ impl Toggle {
112}118}
113119
114impl NoteTag {120impl NoteTag {
115 const ALL: [Self; 2] = [Self::ToDo, Self::Question];121 pub(super) const ALL: [Self; 9] = [
122 Self::ToDo,
123 Self::Important,
124 Self::Question,
125 Self::RememberForLater,
126 Self::Definition,
127 Self::Highlight,
128 Self::Contact,
129 Self::Address,
130 Self::PhoneNumber,
131 ];
116132
117 fn definition(self) -> Definition {133 pub(super) fn definition(self) -> Definition {
118 let (label, action_type, shape) = match self {134 let (label, shape, highlight) = match self {
119 Self::ToDo => ("To Do", 0, CHECKBOX),135 Self::ToDo => ("To Do", CHECKBOX, None),
120 Self::Question => ("Question", 2, 15),136 Self::Important => ("Important", 13, None),
137 Self::Question => ("Question", 15, None),
138 Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)),
139 Self::Definition => ("Definition", 0, Some(0x0000_ff00)),
140 Self::Highlight => ("Highlight", 136, None),
141 Self::Contact => ("Contact", 118, None),
142 Self::Address => ("Address", 23, None),
143 Self::PhoneNumber => ("Phone number", 18, None),
121 };144 };
122 Definition {145 Definition {
123 kind: Kind::TagDefinition {146 kind: Kind::TagDefinition {
124 label: Some(label.into()),147 label: Some(label.into()),
125 action_type: Some(action_type),148 action_type: Some(self as u16),
126 shape: Some(shape),149 shape: Some(shape),
127 color: None,150 // The highlighting tags also set black text.
128 highlight: None,151 color: highlight.map(|_| 0),
152 highlight,
129 },153 },
130 format: Format::default(),154 format: Format::default(),
131 }155 }
...@@ -391,7 +415,7 @@ impl CanvasEditor {...@@ -391,7 +415,7 @@ impl CanvasEditor {
391 ) -> Result<(), EditorError> {415 ) -> Result<(), EditorError> {
392 let outline = self.active_outline();416 let outline = self.active_outline();
393 let (id, title, selection) = (outline.id, outline.title, outline.selection);417 let (id, title, selection) = (outline.id, outline.title, outline.selection);
394 let (container, range, ends) = selected(&outline.document, selection)?;418 let (container, mut range, ends) = selected(&outline.document, selection)?;
395 let mut replacement = outline.document.container(container)?[range.clone()].to_vec();419 let mut replacement = outline.document.container(container)?[range.clone()].to_vec();
396 let mut ranges = covered(&replacement, ends)420 let mut ranges = covered(&replacement, ends)
397 .map(|(node, range)| (node.id, range))421 .map(|(node, range)| (node.id, range))
...@@ -434,6 +458,18 @@ impl CanvasEditor {...@@ -434,6 +458,18 @@ impl CanvasEditor {
434 Formatting::Bullets | Formatting::Numbering => {458 Formatting::Bullets | Formatting::Numbering => {
435 let remove = covered(&replacement, ends)459 let remove = covered(&replacement, ends)
436 .all(|(node, _)| self.list(node).as_ref() == Some(&command));460 .all(|(node, _)| self.list(node).as_ref() == Some(&command));
461 // A list applied after a plain sibling nests under it, as Tab would
462 // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there.
463 let nodes = outline.document.container(container)?;
464 if !remove
465 && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())
466 .is_some_and(|sibling| nodes[sibling].lists.is_empty())
467 && let Some(edit) =
468 crate::document::indent(nodes, container, range.clone(), false)
469 {
470 range = edit.range;
471 replacement = edit.replacement;
472 }
437 let wanted = covered(&replacement, ends)473 let wanted = covered(&replacement, ends)
438 .map(|(node, _)| node)474 .map(|(node, _)| node)
439 .filter(|node| !remove && self.list(node).as_ref() != Some(&command))475 .filter(|node| !remove && self.list(node).as_ref() != Some(&command))
...@@ -632,10 +668,73 @@ impl CanvasEditor {...@@ -632,10 +668,73 @@ impl CanvasEditor {
632 }668 }
633}669}
634670
671/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote
672/// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`,
673/// `c8-tab-1.xml`), then to â–  as `corpus/private` nests bullets, and to i.; outdenting stops at
674/// the first style.
675pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Definition> {
676 const BULLETS: [(&str, &str, u16); 3] = [
677 ("Calibri", "\u{2022}", 1),
678 ("Courier New", "\u{25cb}", 4),
679 ("Wingdings", "\u{a7}", 7),
680 ];
681 const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}'];
682 let Kind::List {
683 font,
684 format: Some(format),
685 restart,
686 bullet,
687 } = &definition.kind
688 else {
689 return None;
690 };
691 let step = |index: usize| {
692 if deeper {
693 Some((index + 1) % 3)
694 } else {
695 index.checked_sub(1)
696 }
697 };
698 let kind = match bullet {
699 Some(_) => {
700 let index = BULLETS.iter().position(|(name, glyph, index)| {
701 (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index))
702 })?;
703 let (name, glyph, index) = BULLETS[step(index)?];
704 Kind::List {
705 font: Some(name.into()),
706 format: Some(glyph.into()),
707 restart: *restart,
708 bullet: Some(index),
709 }
710 }
711 None => {
712 let (prefix, rest) = format.split_once('\u{fffd}')?;
713 let mut rest = rest.chars();
714 let sequence = rest.next()?;
715 let index = SEQUENCES.iter().position(|known| *known == sequence)?;
716 Kind::List {
717 font: font.clone(),
718 format: Some(format!(
719 "{prefix}\u{fffd}{}{}",
720 SEQUENCES[step(index)?],
721 rest.as_str()
722 )),
723 restart: *restart,
724 bullet: None,
725 }
726 }
727 };
728 Some(Definition {
729 kind,
730 format: definition.format.clone(),
731 })
732}
733
635/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet734/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet
636/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores735/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores
637/// from its COM interface (`corpus/outline-edit/tree`).736/// from its COM interface (`corpus/outline-edit/tree`).
638fn list_definition(numbering: bool, format: &Format) -> Definition {737pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
639 let font_size = Some(format.font_size.unwrap_or(11.0));738 let font_size = Some(format.font_size.unwrap_or(11.0));
640 if numbering {739 if numbering {
641 Definition {740 Definition {
...@@ -1007,9 +1106,10 @@ mod tests {...@@ -1007,9 +1106,10 @@ mod tests {
1007 .as_ptr();1106 .as_ptr();
1008 editor.select([at(1, 1); 2].into()).unwrap();1107 editor.select([at(1, 1); 2].into()).unwrap();
1009 editor.format(&mut engine, Formatting::Numbering).unwrap();1108 editor.format(&mut engine, Formatting::Numbering).unwrap();
1109 // An unnumbered sibling leaves the count alone (`evidence/structural-edits/xml/c8-bs-1.xml`).
1010 assert_eq!(1110 assert_eq!(
1011 numbers(&mut engine, &editor),1111 numbers(&mut engine, &editor),
1012 [Some(1), None, Some(1), Some(2)]1112 [Some(1), None, Some(2), Some(3)]
1013 );1113 );
1014 assert_eq!(1114 assert_eq!(
1015 editor.active_outline().shaped.paragraphs[0]1115 editor.active_outline().shaped.paragraphs[0]
...@@ -1036,7 +1136,7 @@ mod tests {...@@ -1036,7 +1136,7 @@ mod tests {
1036 editor.format(&mut engine, Formatting::Outdent).unwrap();1136 editor.format(&mut engine, Formatting::Outdent).unwrap();
1037 assert_eq!(1137 assert_eq!(
1038 numbers(&mut engine, &editor),1138 numbers(&mut engine, &editor),
1039 [Some(1), Some(2), None, Some(1)]1139 [Some(1), Some(2), None, Some(3)]
1040 );1140 );
1041 editor.select(all).unwrap();1141 editor.select(all).unwrap();
1042 let state = editor.format_state().unwrap();1142 let state = editor.format_state().unwrap();
...@@ -1205,6 +1305,57 @@ mod tests {...@@ -1205,6 +1305,57 @@ mod tests {
1205 assert_eq!(editor.active_outline().document, original);1305 assert_eq!(editor.active_outline().document, original);
1206 }1306 }
12071307
1308 #[test]
1309 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {
1310 use onestore::{RevisionIndex, Store, document::Document};
1311 let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one");
1312 let store = Store::parse(bytes).unwrap();
1313 let index = RevisionIndex::parse(&store).unwrap();
1314 let document = Document::parse(&index).unwrap();
1315 let (space, _) = document.pages().unwrap()[0];
1316 let stored = Page::from_space(&document, space).unwrap().definitions;
1317 let stored = stored
1318 .values()
1319 .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. }))
1320 .collect::<Vec<_>>();
1321 assert_eq!(stored.len(), NoteTag::ALL.len());
1322 for tag in NoteTag::ALL {
1323 assert!(stored.contains(&&tag.definition()), "{tag:?}");
1324 }
1325 let mut engine = TextEngine::default();
1326 let mut editor = plain(&mut engine, &["tagged"]);
1327 for tag in NoteTag::ALL {
1328 editor.format(&mut engine, Formatting::Tag(tag)).unwrap();
1329 }
1330 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);
1331 let paragraph = &editor.active_outline().shaped.paragraphs[0];
1332 use crate::outline::TagIcon;
1333 assert_eq!(
1334 paragraph
1335 .tags
1336 .iter()
1337 .map(|tag| tag.icon)
1338 .collect::<Vec<_>>(),
1339 [
1340 TagIcon::CheckBox { checked: false },
1341 TagIcon::Star,
1342 TagIcon::Question,
1343 TagIcon::Highlight,
1344 TagIcon::Contact,
1345 TagIcon::Address,
1346 TagIcon::Phone,
1347 ]
1348 );
1349 // Remember for later and Definition draw no symbol; the newer one's green marks the text.
1350 assert!(
1351 paragraph
1352 .text
1353 .backgrounds()
1354 .all(|(_, color)| color == 0x0000_ff00)
1355 );
1356 assert!(paragraph.text.backgrounds().next().is_some());
1357 }
1358
1208 #[test]1359 #[test]
1209 fn commands_reach_every_cell_a_selection_crosses() {1360 fn commands_reach_every_cell_a_selection_crosses() {
1210 let mut engine = TextEngine::default();1361 let mut engine = TextEngine::default();
crates/canvas/src/editor/page.rs+14-6
...@@ -47,6 +47,19 @@ pub struct ReadOnlyObject {...@@ -47,6 +47,19 @@ pub struct ReadOnlyObject {
47}47}
4848
49impl Content {49impl Content {
50 /// A picture drawn as a placeholder because it has no data, size or decodable pixels.
51 pub(crate) fn unavailable(
52 source: PageObject,
53 engine: &mut TextEngine,
54 ) -> Result<Self, EditorError> {
55 Ok(Self::ReadOnly(ReadOnlyObject::new(
56 source,
57 [0.0; 2],
58 "Image unavailable\nRead-only",
59 engine,
60 )?))
61 }
62
50 pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> {63 pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> {
51 match self {64 match self {
52 Self::Outline { source, .. } => Some((source.id, &source.layout)),65 Self::Outline { source, .. } => Some((source.id, &source.layout)),
...@@ -290,12 +303,7 @@ pub(crate) fn build(...@@ -290,12 +303,7 @@ pub(crate) fn build(
290 || source.layout.max_width.is_none()303 || source.layout.max_width.is_none()
291 || source.layout.max_height.is_none()304 || source.layout.max_height.is_none()
292 {305 {
293 objects.push(Content::ReadOnly(ReadOnlyObject::new(306 objects.push(Content::unavailable(object, engine)?);
294 object,
295 page.margin_origin,
296 "Image unavailable\nRead-only",
297 engine,
298 )?));
299 continue;307 continue;
300 }308 }
301 let origin = [309 let origin = [
crates/canvas/src/editor/table.rs+25-6
...@@ -101,12 +101,23 @@ impl CanvasEditor {...@@ -101,12 +101,23 @@ impl CanvasEditor {
101 Paragraph::new(String::new(), format.clone()),101 Paragraph::new(String::new(), format.clone()),
102 ],102 ],
103 )?;103 )?;
104 let id = new_id()?;
105 // The table's paragraph holds the list, tags and children (`evidence/structural-edits/
106 // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`).
107 let children = split.replacement.split_off(2);
108 let head = &mut split.replacement[0];
109 let lists = std::mem::take(&mut head.lists);
110 let mut tags = std::mem::take(&mut head.tags);
111 tags.append(&mut head.text_mut().unwrap().tags);
112 let collapsed = std::mem::take(&mut split.replacement[1].collapsed);
113 let tail = split.replacement[1].id;
104 let cells = split114 let cells = split
105 .replacement115 .replacement
106 .drain(..)116 .drain(..)
107 .map(|mut paragraph| {117 .map(|mut paragraph| {
108 paragraph.level = 1;118 paragraph.level = 1;
109 paragraph.parent = None;119 paragraph.parent = None;
120 paragraph.lists.clear();
110 Ok(TableCell {121 Ok(TableCell {
111 id: new_id()?,122 id: new_id()?,
112 layout: Default::default(),123 layout: Default::default(),
...@@ -118,15 +129,15 @@ impl CanvasEditor {...@@ -118,15 +129,15 @@ impl CanvasEditor {
118 })129 })
119 .collect::<Result<Vec<_>, EditError>>()?;130 .collect::<Result<Vec<_>, EditError>>()?;
120 let wrapper = PageParagraph {131 let wrapper = PageParagraph {
121 id: new_id()?,132 id,
122 parent: source.parent,133 parent: source.parent,
123 level: source.level,134 level: source.level,
124 style: None,135 style: None,
125 format: source.format.clone(),136 format: source.format.clone(),
126 lists: Vec::new(),137 lists,
127 tags: Vec::new(),138 tags,
128 media: Default::default(),139 media: Default::default(),
129 collapsed: false,140 collapsed,
130 content: ParagraphContent::Table(onestore::page::Table {141 content: ParagraphContent::Table(onestore::page::Table {
131 id: new_id()?,142 id: new_id()?,
132 columns: vec![143 columns: vec![
...@@ -146,6 +157,14 @@ impl CanvasEditor {...@@ -146,6 +157,14 @@ impl CanvasEditor {
146 }),157 }),
147 };158 };
148 split.replacement.push(wrapper);159 split.replacement.push(wrapper);
160 split
161 .replacement
162 .extend(children.into_iter().map(|mut child| {
163 if child.parent == Some(tail) {
164 child.parent = Some(id);
165 }
166 child
167 }));
149 return self.commit(168 return self.commit(
150 engine,169 engine,
151 split,170 split,
...@@ -261,7 +280,7 @@ impl CanvasEditor {...@@ -261,7 +280,7 @@ impl CanvasEditor {
261 .leaf(focus.paragraph)280 .leaf(focus.paragraph)
262 .ok_or(EditError::InvalidRange)?;281 .ok_or(EditError::InvalidRange)?;
263 let Some(cell) = cell.filter(|_| anchor == focus) else {282 let Some(cell) = cell.filter(|_| anchor == focus) else {
264 return self.insert(engine, "\n");283 return self.split(engine);
265 };284 };
266 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;285 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
267 let ParagraphContent::Table(table) = &location.node.content else {286 let ParagraphContent::Table(table) = &location.node.content else {
...@@ -284,7 +303,7 @@ impl CanvasEditor {...@@ -284,7 +303,7 @@ impl CanvasEditor {
284 && local + 1 == row.cells[location.column].paragraphs.len()303 && local + 1 == row.cells[location.column].paragraphs.len()
285 && focus.offset == text.utf16_offset(text.text().len())?;304 && focus.offset == text.utf16_offset(text.text().len())?;
286 if !exit && !append {305 if !exit && !append {
287 return self.insert(engine, "\n");306 return self.split(engine);
288 }307 }
289 let mut wrapper = location.node.clone();308 let mut wrapper = location.node.clone();
290 let ParagraphContent::Table(table) = &mut wrapper.content else {309 let ParagraphContent::Table(table) = &mut wrapper.content else {
crates/canvas/src/gpu/mod.rs+16-1
...@@ -98,7 +98,12 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {...@@ -98,7 +98,12 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {
9898
99const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg");99const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg");
100const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");100const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");
101const STAR: &str = include_str!("../../assets/tags/star.svg");
101const QUESTION: &str = include_str!("../../assets/tags/question.svg");102const QUESTION: &str = include_str!("../../assets/tags/question.svg");
103const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg");
104const CONTACT: &str = include_str!("../../assets/tags/contact.svg");
105const ADDRESS: &str = include_str!("../../assets/tags/address.svg");
106const PHONE: &str = include_str!("../../assets/tags/phone.svg");
102const MUSIC: &str = include_str!("../../assets/tags/music.svg");107const MUSIC: &str = include_str!("../../assets/tags/music.svg");
103const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");108const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
104const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");109const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
...@@ -110,7 +115,12 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {...@@ -110,7 +115,12 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
110 match icon {115 match icon {
111 TagIcon::CheckBox { checked: false } => &[CHECKBOX],116 TagIcon::CheckBox { checked: false } => &[CHECKBOX],
112 TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK],117 TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK],
118 TagIcon::Star => &[STAR],
113 TagIcon::Question => &[QUESTION],119 TagIcon::Question => &[QUESTION],
120 TagIcon::Highlight => &[HIGHLIGHT],
121 TagIcon::Contact => &[CONTACT],
122 TagIcon::Address => &[ADDRESS],
123 TagIcon::Phone => &[PHONE],
114 TagIcon::Music => &[MUSIC],124 TagIcon::Music => &[MUSIC],
115 TagIcon::Exclamation => &[EXCLAMATION],125 TagIcon::Exclamation => &[EXCLAMATION],
116 TagIcon::RedSquare => &[RED_SQUARE],126 TagIcon::RedSquare => &[RED_SQUARE],
...@@ -140,7 +150,7 @@ mod tests {...@@ -140,7 +150,7 @@ mod tests {
140 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();150 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
141 let (device, queue) =151 let (device, queue) =
142 pollster::block_on(adapter.request_device(&Default::default())).unwrap();152 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
143 let size = [256, 64];153 let size = [448, 64];
144 let target = device.create_texture(&wgpu::TextureDescriptor {154 let target = device.create_texture(&wgpu::TextureDescriptor {
145 label: Some("Tag readback test"),155 label: Some("Tag readback test"),
146 size: wgpu::Extent3d {156 size: wgpu::Extent3d {
...@@ -165,7 +175,12 @@ mod tests {...@@ -165,7 +175,12 @@ mod tests {
165 let icons = [175 let icons = [
166 TagIcon::CheckBox { checked: false },176 TagIcon::CheckBox { checked: false },
167 TagIcon::CheckBox { checked: true },177 TagIcon::CheckBox { checked: true },
178 TagIcon::Star,
168 TagIcon::Question,179 TagIcon::Question,
180 TagIcon::Highlight,
181 TagIcon::Contact,
182 TagIcon::Address,
183 TagIcon::Phone,
169 TagIcon::Music,184 TagIcon::Music,
170 TagIcon::Exclamation,185 TagIcon::Exclamation,
171 TagIcon::RedSquare,186 TagIcon::RedSquare,
crates/canvas/src/gpu/page.rs+41-10
...@@ -92,10 +92,13 @@ fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti...@@ -92,10 +92,13 @@ fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti
92 rect,92 rect,
93 color: paper.shade(colorref(0x00e4ddd6)),93 color: paper.shade(colorref(0x00e4ddd6)),
94 });94 });
95 primitives.push(Primitive::Rect {95 let inner = [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0];
96 rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0],96 if inner[0] < inner[2] && inner[1] < inner[3] {
97 color: paper.shade(colorref(0x00faf7f3)),97 primitives.push(Primitive::Rect {
98 });98 rect: inner,
99 color: paper.shade(colorref(0x00faf7f3)),
100 });
101 }
99}102}
100103
101#[derive(Debug)]104#[derive(Debug)]
...@@ -140,10 +143,11 @@ impl From<EditorError> for SceneError {...@@ -140,10 +143,11 @@ impl From<EditorError> for SceneError {
140143
141impl PageScene {144impl PageScene {
142 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {145 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
143 let objects = crate::editor::page::build(&mut page, engine, false)146 let mut objects = crate::editor::page::build(&mut page, engine, false)
144 .map_err(SceneError::from)?147 .map_err(SceneError::from)?
145 .objects;148 .objects;
146 let pictures = Self::decode_images(&objects, None)?;149 let pictures = Self::decode_images(&objects, None)?;
150 pictures.mark_unavailable(&mut objects, engine)?;
147 Ok(Self {151 Ok(Self {
148 reference: Some(objects),152 reference: Some(objects),
149 ..pictures153 ..pictures
...@@ -154,12 +158,35 @@ impl PageScene {...@@ -154,12 +158,35 @@ impl PageScene {
154 page: Page,158 page: Page,
155 engine: &mut TextEngine,159 engine: &mut TextEngine,
156 ) -> Result<(Self, CanvasEditor), SceneError> {160 ) -> Result<(Self, CanvasEditor), SceneError> {
157 let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;161 let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;
158 Ok((Self::decode_images(&editor.objects, Some(&editor))?, editor))162 let scene = Self::decode_images(&editor.objects, Some(&editor))?;
163 scene.mark_unavailable(&mut editor.objects, engine)?;
164 Ok((scene, editor))
165 }
166
167 /// Turns page pictures that did not decode into placeholders; their stored data is kept.
168 fn mark_unavailable(
169 &self,
170 objects: &mut Vec<Content>,
171 engine: &mut TextEngine,
172 ) -> Result<(), SceneError> {
173 *objects = std::mem::take(objects)
174 .into_iter()
175 .map(|object| match object {
176 Content::Image(source)
177 if !self.images.contains_key(&source.id)
178 && !self.backgrounds.contains_key(&source.id) =>
179 {
180 Content::unavailable(onestore::page::PageObject::Image(source), engine)
181 }
182 object => Ok(object),
183 })
184 .collect::<Result<_, _>>()?;
185 Ok(())
159 }186 }
160187
161 /// Decodes every picture the page draws, page-level ones and those inside outlines,188 /// Decodes every picture the page draws, page-level ones and those inside outlines,
162 /// except the template backgrounds it recognises.189 /// except the template backgrounds it recognises; one without decodable data is left out.
163 fn decode_images(190 fn decode_images(
164 objects: &[Content],191 objects: &[Content],
165 editor: Option<&CanvasEditor>,192 editor: Option<&CanvasEditor>,
...@@ -215,8 +242,9 @@ impl PageScene {...@@ -215,8 +242,9 @@ impl PageScene {
215 let mut images = std::collections::BTreeMap::new();242 let mut images = std::collections::BTreeMap::new();
216 let mut bytes = 0_u64;243 let mut bytes = 0_u64;
217 for (id, encoded) in payloads {244 for (id, encoded) in payloads {
218 let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?)245 let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else {
219 .map_err(SceneError::Image)?;246 continue;
247 };
220 bytes += image.pixels().len() as u64;248 bytes += image.pixels().len() as u64;
221 if bytes > draw::MAX_IMAGE_BYTES {249 if bytes > draw::MAX_IMAGE_BYTES {
222 return Err(SceneError::Image(RenderError::ImageBudget));250 return Err(SceneError::Image(RenderError::ImageBudget));
...@@ -284,6 +312,9 @@ impl PageScene {...@@ -284,6 +312,9 @@ impl PageScene {
284 crate::outline::ObjectKind::Unsupported(_) => {312 crate::outline::ObjectKind::Unsupported(_) => {
285 append_placeholder(rect, paper, primitives)313 append_placeholder(rect, paper, primitives)
286 }314 }
315 crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => {
316 append_placeholder(rect, paper, primitives)
317 }
287 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}318 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}
288 }319 }
289 if let Some(label) = object.label() {320 if let Some(label) = object.label() {
crates/canvas/src/interaction/accessibility.rs+5
...@@ -597,7 +597,12 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>...@@ -597,7 +597,12 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
597 use crate::outline::TagIcon;597 use crate::outline::TagIcon;
598 let fallback = match tag.icon {598 let fallback = match tag.icon {
599 TagIcon::CheckBox { .. } => "To do",599 TagIcon::CheckBox { .. } => "To do",
600 TagIcon::Star => "Important",
600 TagIcon::Question => "Question",601 TagIcon::Question => "Question",
602 TagIcon::Highlight => "Highlight",
603 TagIcon::Contact => "Contact",
604 TagIcon::Address => "Address",
605 TagIcon::Phone => "Phone number",
601 TagIcon::Music => "Music",606 TagIcon::Music => "Music",
602 TagIcon::Exclamation => "Critical",607 TagIcon::Exclamation => "Critical",
603 TagIcon::RedSquare => "Project A",608 TagIcon::RedSquare => "Project A",
crates/canvas/src/interaction/mod.rs+10-1
...@@ -819,7 +819,7 @@ impl PageView {...@@ -819,7 +819,7 @@ impl PageView {
819 self.commit_text(text)819 self.commit_text(text)
820 }820 }
821821
822 /// Text committed by an input method or pasted, line breaks included.822 /// Text committed by an input method, line breaks included.
823 pub fn commit_text(&mut self, text: String) -> Result<Response> {823 pub fn commit_text(&mut self, text: String) -> Result<Response> {
824 if !self.accepts_text() {824 if !self.accepts_text() {
825 return Ok(Response::default());825 return Ok(Response::default());
...@@ -828,6 +828,15 @@ impl PageView {...@@ -828,6 +828,15 @@ impl PageView {
828 self.edited()828 self.edited()
829 }829 }
830830
831 /// Clipboard text; see [`CanvasEditor::paste`].
832 pub fn paste(&mut self, text: &str) -> Result<Response> {
833 if !self.accepts_text() {
834 return Ok(Response::default());
835 }
836 self.editor.paste(&mut self.engine, text)?;
837 self.edited()
838 }
839
831 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.840 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.
832 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {841 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {
833 if !self.accepts_text() {842 if !self.accepts_text() {
crates/canvas/src/outline.rs+55-9
...@@ -42,6 +42,8 @@ struct Block {...@@ -42,6 +42,8 @@ struct Block {
42 state: (f64, Option<f32>),42 state: (f64, Option<f32>),
43 /// A table's vertical coordinates before its offset.43 /// A table's vertical coordinates before its offset.
44 rel: Vec<f32>,44 rel: Vec<f32>,
45 /// The number the next numbered sibling at the node's level continues from.
46 count: Option<u32>,
45}47}
4648
47/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.49/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.
...@@ -142,6 +144,8 @@ fn verticals<'a>(...@@ -142,6 +144,8 @@ fn verticals<'a>(
142144
143/// OneNote centers a file's icon and name in a column this wide.145/// OneNote centers a file's icon and name in a column this wide.
144const ATTACHMENT_WIDTH: f32 = 54.0;146const ATTACHMENT_WIDTH: f32 = 54.0;
147/// The grey OneNote gives secondary text such as the page date (its `PageDateTime` style).
148const PLACEHOLDER: u32 = 0x0080_8080;
145149
146#[derive(Clone)]150#[derive(Clone)]
147pub struct TableLayout {151pub struct TableLayout {
...@@ -201,7 +205,12 @@ pub struct ParagraphLayout {...@@ -201,7 +205,12 @@ pub struct ParagraphLayout {
201#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]205#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
202pub enum TagIcon {206pub enum TagIcon {
203 CheckBox { checked: bool },207 CheckBox { checked: bool },
208 Star,
204 Question,209 Question,
210 Highlight,
211 Contact,
212 Address,
213 Phone,
205 Music,214 Music,
206 Exclamation,215 Exclamation,
207 RedSquare,216 RedSquare,
...@@ -255,8 +264,16 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> {...@@ -255,8 +264,16 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> {
255 .then_some(size)264 .then_some(size)
256}265}
257266
267/// The table OneNote writes on every outline it creates, standing in for a missing one.
268const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0];
269
258pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {270pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {
259 if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {271 let indents = if indents.is_empty() {
272 &DEFAULT_INDENTS
273 } else {
274 indents
275 };
276 if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
260 return Err(LayoutError::InvalidIndentation);277 return Err(LayoutError::InvalidIndentation);
261 }278 }
262 let known = (level as usize).min(indents.len() - 1);279 let known = (level as usize).min(indents.len() - 1);
...@@ -282,8 +299,19 @@ fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {...@@ -282,8 +299,19 @@ fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {
282 number.map(|(number, restart)| if restart { number } else { next(previous) })299 number.map(|(number, restart)| if restart { number } else { next(previous) })
283}300}
284301
285/// Removes deeper paragraphs' entries from a flow's `(level, number)` stack of the latest302/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without
286/// paragraph at each level, and the previous sibling's at `level`, returning that one's number.303/// a number leave the count alone, as do empty numbered ones after another number
304/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`).
305fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Option<u32> {
306 number
307 .filter(|_| {
308 previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty())
309 })
310 .or(previous)
311}
312
313/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest
314/// paragraph at each level, and the previous sibling's at `level`, returning that one's count.
287fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {315fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
288 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {316 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {
289 siblings.pop();317 siblings.pop();
...@@ -296,7 +324,7 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {...@@ -296,7 +324,7 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
296324
297/// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4325/// `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`.326/// upper and lower letters, as OneNote 2010's `numberSequence`.
299fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {327pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
300 let roman = |number: u32| {328 let roman = |number: u32| {
301 const DIGITS: [(u32, &str); 13] = [329 const DIGITS: [(u32, &str); 13] = [
302 (1000, "M"),330 (1000, "M"),
...@@ -471,8 +499,13 @@ impl ParagraphLayout {...@@ -471,8 +499,13 @@ impl ParagraphLayout {
471 else {499 else {
472 return Err(LayoutError::InvalidList);500 return Err(LayoutError::InvalidList);
473 };501 };
502 let mut color = definition.format.color;
474 let value = match value.split_once('\u{fffd}') {503 let value = match value.split_once('\u{fffd}') {
475 Some((prefix, rest)) => {504 Some((prefix, rest)) => {
505 // An empty numbered paragraph shows its number as a grey placeholder.
506 if source.text.text().is_empty() {
507 color = Some(PLACEHOLDER);
508 }
476 let current = restart.unwrap_or(next(previous));509 let current = restart.unwrap_or(next(previous));
477 number = Some((current, restart.is_some()));510 number = Some((current, restart.is_some()));
478 let mut rest = rest.chars();511 let mut rest = rest.chars();
...@@ -492,7 +525,7 @@ impl ParagraphLayout {...@@ -492,7 +525,7 @@ impl ParagraphLayout {
492 Format {525 Format {
493 font: font.clone().or_else(|| definition.format.font.clone()),526 font: font.clone().or_else(|| definition.format.font.clone()),
494 font_size: definition.format.font_size.or(format.font_size),527 font_size: definition.format.font_size.or(format.font_size),
495 color: definition.format.color,528 color,
496 ..Format::default()529 ..Format::default()
497 },530 },
498 );531 );
...@@ -517,8 +550,13 @@ impl ParagraphLayout {...@@ -517,8 +550,13 @@ impl ParagraphLayout {
517 Some(3) => TagIcon::CheckBox {550 Some(3) => TagIcon::CheckBox {
518 checked: tag.status & 1 != 0,551 checked: tag.status & 1 != 0,
519 },552 },
553 Some(13) => TagIcon::Star,
520 Some(15) => TagIcon::Question,554 Some(15) => TagIcon::Question,
521 Some(17) => TagIcon::Exclamation,555 Some(17) => TagIcon::Exclamation,
556 Some(18) => TagIcon::Phone,
557 Some(23) => TagIcon::Address,
558 Some(118) => TagIcon::Contact,
559 Some(136) => TagIcon::Highlight,
522 Some(100) => TagIcon::RedSquare,560 Some(100) => TagIcon::RedSquare,
523 Some(101) => TagIcon::YellowSquare,561 Some(101) => TagIcon::YellowSquare,
524 Some(102) => TagIcon::BlueSquare,562 Some(102) => TagIcon::BlueSquare,
...@@ -702,6 +740,7 @@ impl OutlineLayout {...@@ -702,6 +740,7 @@ impl OutlineLayout {
702 extent: [f32::NEG_INFINITY; 2],740 extent: [f32::NEG_INFINITY; 2],
703 state,741 state,
704 rel: Vec::new(),742 rel: Vec::new(),
743 count: None,
705 };744 };
706 if !hidden {745 if !hidden {
707 let (space, height, flow, extent) = match &node.content {746 let (space, height, flow, extent) = match &node.content {
...@@ -836,7 +875,8 @@ impl OutlineLayout {...@@ -836,7 +875,8 @@ impl OutlineLayout {
836 return Err(LayoutError::InvalidSpacing);875 return Err(LayoutError::InvalidSpacing);
837 }876 }
838 }877 }
839 siblings.push((node.level, number));878 block.count = tally(node, number, previous);
879 siblings.push((node.level, block.count));
840 block.state = state;880 block.state = state;
841 if depth == 0 {881 if depth == 0 {
842 result.blocks.push(block);882 result.blocks.push(block);
...@@ -932,7 +972,7 @@ impl OutlineLayout {...@@ -932,7 +972,7 @@ impl OutlineLayout {
932 for root in (0..range.start).rev() {972 for root in (0..range.start).rev() {
933 if nodes[root].level < level {973 if nodes[root].level < level {
934 level = nodes[root].level;974 level = nodes[root].level;
935 siblings.push((level, self.number(nodes, root).map(|(number, _)| number)));975 siblings.push((level, self.blocks[root].count));
936 if level <= 1 {976 if level <= 1 {
937 break;977 break;
938 }978 }
...@@ -990,10 +1030,11 @@ impl OutlineLayout {...@@ -990,10 +1030,11 @@ impl OutlineLayout {
990 let previous = sibling(&mut siblings, nodes[root].level);1030 let previous = sibling(&mut siblings, nodes[root].level);
991 let number = self.number(nodes, root);1031 let number = self.number(nodes, root);
992 let now = follows(number, previous);1032 let now = follows(number, previous);
993 if now != number.map(|(number, _)| number) {1033 let counted = tally(&nodes[root], now, previous);
1034 if now != number.map(|(number, _)| number) || counted != self.blocks[root].count {
994 renumbered = root + 1;1035 renumbered = root + 1;
995 }1036 }
996 siblings.push((nodes[root].level, now));1037 siblings.push((nodes[root].level, counted));
997 if nodes[root].level <= 1 && renumbered <= root {1038 if nodes[root].level <= 1 && renumbered <= root {
998 break;1039 break;
999 }1040 }
...@@ -1678,8 +1719,13 @@ mod tests {...@@ -1678,8 +1719,13 @@ mod tests {
1678 });1719 });
1679 for (shape, expected) in [1720 for (shape, expected) in [
1680 (3, TagIcon::CheckBox { checked: true }),1721 (3, TagIcon::CheckBox { checked: true }),
1722 (13, TagIcon::Star),
1681 (15, TagIcon::Question),1723 (15, TagIcon::Question),
1682 (17, TagIcon::Exclamation),1724 (17, TagIcon::Exclamation),
1725 (18, TagIcon::Phone),
1726 (23, TagIcon::Address),
1727 (118, TagIcon::Contact),
1728 (136, TagIcon::Highlight),
1683 (100, TagIcon::RedSquare),1729 (100, TagIcon::RedSquare),
1684 (101, TagIcon::YellowSquare),1730 (101, TagIcon::YellowSquare),
1685 (102, TagIcon::BlueSquare),1731 (102, TagIcon::BlueSquare),
crates/canvas/tests/reopen.rs created+181
...@@ -0,0 +1,181 @@
1#![cfg(feature = "gpu")]
2
3use canvas::{gpu::page::PageScene, layout::TextEngine};
4use onestore::{
5 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
6 document::Document,
7 page::{Page, PageObject, ParagraphContent},
8};
9use std::{fs, path::Path};
10
11fn pages(section: &[u8]) -> Vec<Page> {
12 let store = Store::parse(section).unwrap();
13 let index = RevisionIndex::parse(&store).unwrap();
14 let document = Document::parse(&index).unwrap();
15 spaces(&document)
16 .into_iter()
17 .map(|space| Page::from_space(&document, space).unwrap())
18 .collect()
19}
20
21fn spaces(document: &Document<'_>) -> Vec<ExGuid> {
22 let mut spaces: Vec<_> = document
23 .pages()
24 .unwrap()
25 .into_iter()
26 .map(|(space, _)| space)
27 .collect();
28 spaces.dedup();
29 spaces
30}
31
32/// What `notebook::session::Section::import_page` writes: a new titled page, then the
33/// editor's body objects saved onto it.
34#[test]
35fn an_imported_editor_page_reopens_with_its_title_editable() {
36 let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap();
37 let mut engine = TextEngine::default();
38 let (_, editor) = PageScene::from_page(pages(&section).remove(0), &mut engine).unwrap();
39 let copy = editor.page().unwrap().copy().unwrap();
40 let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap();
41 let created = PreparedEdit::create_page(&section, &creation).unwrap();
42 let mut after = pages(created.as_bytes()).pop().unwrap();
43 after
44 .objects
45 .retain(|object| matches!(object, PageObject::Title(_)));
46 after.objects.extend(
47 copy.objects
48 .into_iter()
49 .filter(|object| !matches!(object, PageObject::Title(_))),
50 );
51 after.definitions = copy.definitions;
52 let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap();
53
54 let reopened = pages(saved.as_bytes()).pop().unwrap();
55 let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap();
56 assert_eq!(editor.page().unwrap(), reopened);
57 let texts = |title: bool| -> Vec<String> {
58 editor
59 .outlines()
60 .iter()
61 .filter(|outline| outline.title == title)
62 .map(|outline| {
63 outline
64 .document()
65 .paragraphs()
66 .next()
67 .unwrap()
68 .text()
69 .to_string()
70 })
71 .collect()
72 };
73 assert_eq!(texts(true), ["Imported 🦋"]);
74 assert_eq!(texts(false), ["Body 🦀 é"]);
75}
76
77#[test]
78fn every_corpus_page_opens() {
79 fn sections(dir: &Path, found: &mut Vec<std::path::PathBuf>) {
80 for entry in fs::read_dir(dir).unwrap() {
81 let path = entry.unwrap().path();
82 if path.is_dir() {
83 sections(&path, found);
84 } else if path.extension().is_some_and(|extension| extension == "one") {
85 found.push(path);
86 }
87 }
88 }
89 let mut found = Vec::new();
90 sections(
91 &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"),
92 &mut found,
93 );
94 let mut engine = TextEngine::default();
95 let (mut pages, mut failures) = (0, Vec::new());
96 for path in found {
97 let bytes = fs::read(&path).unwrap();
98 // Malformed and encrypted sections are the reader's tests' concern.
99 let Ok(store) = Store::parse(&bytes) else {
100 continue;
101 };
102 let Ok(index) = RevisionIndex::parse(&store) else {
103 continue;
104 };
105 let Ok(document) = Document::parse(&index) else {
106 continue;
107 };
108 for space in spaces(&document) {
109 pages += 1;
110 let result = Page::from_space(&document, space)
111 .map_err(|error| format!("{error:?}"))
112 .and_then(|page| {
113 PageScene::from_page(page, &mut engine).map_err(|error| format!("{error:?}"))
114 });
115 if let Err(error) = result {
116 failures.push(format!("{} {space}: {error}", path.display()));
117 }
118 }
119 }
120 assert!(pages > 2000, "{pages}");
121 assert!(failures.is_empty(), "{}", failures.join("\n"));
122}
123
124/// A page opens whatever its pictures hold, and saving keeps their stored data.
125#[test]
126fn undecodable_pictures_show_placeholders_and_keep_their_data() {
127 let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
128 let section = |path: &str| pages(&fs::read(corpus.join(path)).unwrap());
129 let mut engine = TextEngine::default();
130
131 // A PNG whose checksum does not match, inside an outline.
132 let page = section("writer/complex-01/notebook/synthetic.one")
133 .into_iter()
134 .find(|page| page.title.starts_with("Fictitious"))
135 .unwrap();
136 let picture = page
137 .objects
138 .iter()
139 .find_map(|object| match object {
140 PageObject::Outline(outline) => {
141 outline
142 .paragraphs
143 .iter()
144 .find_map(|paragraph| match &paragraph.content {
145 ParagraphContent::Image(image) => Some(image.id),
146 _ => None,
147 })
148 }
149 _ => None,
150 })
151 .unwrap();
152 let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap();
153 assert!(scene.image(picture).is_none());
154 assert_eq!(editor.page().unwrap(), page);
155
156 // A TIFF on the page renders; with its data damaged it becomes the placeholder.
157 let mut page = section("media-edit/candidate/Features.one")
158 .into_iter()
159 .find(|page| page.title == "Image tiff")
160 .unwrap();
161 let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap();
162 let PageObject::Image(tiff) = page
163 .objects
164 .iter_mut()
165 .find(|object| matches!(object, PageObject::Image(_)))
166 .unwrap()
167 else {
168 unreachable!()
169 };
170 assert!(scene.image(tiff.id).is_some());
171 tiff.bytes = Some(b"not a picture".as_slice().into());
172 let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap();
173 assert_eq!(
174 scene
175 .read_only(Some(&editor))
176 .map(|object| object.message)
177 .collect::<Vec<_>>(),
178 ["Image unavailable\nRead-only"]
179 );
180 assert_eq!(editor.page().unwrap(), page);
181}
crates/draw/Cargo.toml+1-1
...@@ -13,7 +13,7 @@ render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"]...@@ -13,7 +13,7 @@ render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"]
13parley.workspace = true13parley.workspace = true
1414
15bytemuck = { version = "1.25", features = ["derive"], optional = true }15bytemuck = { version = "1.25", features = ["derive"], optional = true }
16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true }16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
17roxmltree = { version = "0.21", optional = true }17roxmltree = { version = "0.21", optional = true }
18swash = { version = "0.2.10", optional = true }18swash = { version = "0.2.10", optional = true }
19wgpu = { workspace = true, optional = true }19wgpu = { workspace = true, optional = true }
crates/onestore/src/page/mod.rs+14-3
...@@ -624,11 +624,22 @@ impl Page {...@@ -624,11 +624,22 @@ impl Page {
624 Ok(width)624 Ok(width)
625 })625 })
626 .transpose()?;626 .transpose()?;
627 let title_text = |node: &Element<'_>| {
628 node.extra.first().is_some_and(|fields| {
629 fields.iter().any(|field| {
630 field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData)
631 })
632 })
633 };
627 let mut outline = Outline {634 let mut outline = Outline {
628 id,635 id,
629 title: fields.iter().any(|field| {636 // OneNote flags the outline and its paragraph; `PageCreation` flags only
630 field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData)637 // the paragraph, and OneNote still shows that outline as the title.
631 }),638 title: title_text(node)
639 || node
640 .children
641 .iter()
642 .any(|id| revision.nodes.get(id).is_some_and(title_text)),
632 min_width,643 min_width,
633 layout: node.layout.clone(),644 layout: node.layout.clone(),
634 indents: indents.clone(),645 indents: indents.clone(),
crates/snowbound/assets/tags/address.svg deleted-34
...@@ -1,34 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#9a4a2a"/>
5 <stop offset="1" stop-color="#7a3218"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#b58a3a"/>
9 <stop offset="1" stop-color="#8a6420"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#fff1b0"/>
13 <stop offset="1" stop-color="#f2c25c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e0672e"/>
17 <stop offset="1" stop-color="#a8401a"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#9a5a1e"/>
21 <stop offset="1" stop-color="#6e3a10"/>
22 </linearGradient>
23 <linearGradient id="layer5" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
24 <stop offset="0" stop-color="#e3a54c"/>
25 <stop offset="1" stop-color="#c07a26"/>
26 </linearGradient>
27 </defs>
28 <path d="M10.5 1.5H13V5.5L10.5 3.5Z" fill="url(#layer0)"/>
29 <path d="M3 7H13V15H3Z" fill="url(#layer1)"/>
30 <path d="M4 7.5H12V14H4Z" fill="url(#layer2)"/>
31 <path d="M8 0.75L15.25 8L14 9.25L8 3.25L2 9.25L0.75 8Z" fill="url(#layer3)"/>
32 <path d="M6.75 9.5H9.25V14H6.75Z" fill="url(#layer4)"/>
33 <path d="M7.25 10H8.75V14H7.25Z" fill="url(#layer5)"/>
34</svg>
crates/snowbound/assets/tags/contact.svg deleted-25
...@@ -1,25 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#8aa3c8"/>
5 <stop offset="1" stop-color="#5d7aa6"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#d3e0f2"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#e39a5c"/>
13 <stop offset="1" stop-color="#b8612a"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e05a30"/>
17 <stop offset="1" stop-color="#b03a1a"/>
18 </linearGradient>
19 </defs>
20 <path d="M1.5 2.5H14.5A1 1 0 0 1 15.5 3.5V12.5A1 1 0 0 1 14.5 13.5H1.5A1 1 0 0 1 0.5 12.5V3.5A1 1 0 0 1 1.5 2.5Z" fill="url(#layer0)"/>
21 <path d="M1.75 3.5H14.25A0.25 0.25 0 0 1 14.5 3.75V12.25A0.25 0.25 0 0 1 14.25 12.5H1.75A0.25 0.25 0 0 1 1.5 12.25V3.75A0.25 0.25 0 0 1 1.75 3.5Z" fill="url(#layer1)"/>
22 <path d="M3.5 6.5A1.75 1.75 0 1 0 7 6.5A1.75 1.75 0 1 0 3.5 6.5Z" fill="url(#layer2)"/>
23 <path d="M2.25 11.75C2.25 9.5 3.6 8.75 5.25 8.75C6.9 8.75 8.25 9.5 8.25 11.75Z" fill="url(#layer3)"/>
24 <path d="M9.5 5.5H13.5V6.75H9.5ZM9.5 8H13.5V9.25H9.5ZM9.5 10.5H12.5V11.75H9.5Z" fill="#4d6d9c"/>
25</svg>
crates/snowbound/assets/tags/highlight.svg deleted-29
...@@ -1,29 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#b23a64"/>
5 <stop offset="1" stop-color="#8c2248"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#7a2a40"/>
9 <stop offset="1" stop-color="#5a1a2c"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ff9ec0"/>
13 <stop offset="1" stop-color="#e0487c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e76b98"/>
17 <stop offset="1" stop-color="#c0305e"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#ff5f8f"/>
21 <stop offset="1" stop-color="#e0205a"/>
22 </linearGradient>
23 </defs>
24 <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/>
25 <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/>
26 <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/>
27 <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/>
28 <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/>
29</svg>
crates/snowbound/assets/tags/phone.svg deleted-14
...@@ -1,14 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#7f8b99"/>
5 <stop offset="1" stop-color="#56606d"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#f4f7fb"/>
9 <stop offset="1" stop-color="#b3c1d3"/>
10 </linearGradient>
11 </defs>
12 <path d="M12.86 14.11L12.59 14.07L12.31 14.03L12.04 13.98L11.77 13.92L11.49 13.86L11.22 13.79L10.96 13.72L10.69 13.64L10.42 13.56L10.16 13.46L9.9 13.37L9.64 13.27L9.38 13.16L9.13 13.04L8.88 12.92L8.63 12.8L8.38 12.67L8.14 12.53L7.9 12.39L7.66 12.25L7.43 12.09L7.2 11.94L6.97 11.78L6.75 11.61L6.53 11.44L6.31 11.26L6.1 11.08L5.89 10.89L5.69 10.7L5.49 10.51L5.3 10.31L5.11 10.11L4.92 9.9L4.74 9.69L4.56 9.47L4.39 9.25L4.22 9.03L4.06 8.8L3.91 8.57L3.75 8.34L3.61 8.1L3.47 7.86L3.33 7.62L3.2 7.37L3.08 7.12L2.96 6.87L2.84 6.62L2.73 6.36L2.63 6.1L2.54 5.84L2.44 5.58L2.36 5.31L2.28 5.04L2.21 4.78L2.14 4.51L2.08 4.23L2.02 3.96L1.97 3.69L1.93 3.41L1.89 3.14L7.45 2.45L7.47 2.61L7.5 2.76L7.52 2.91L7.55 3.06L7.59 3.21L7.63 3.36L7.67 3.51L7.71 3.66L7.76 3.81L7.81 3.96L7.86 4.1L7.77 4.3L7.44 4.62L6.98 5.02L6.51 5.45L6.15 5.85L5.99 6.17L6.07 6.37L6.18 6.55L6.29 6.73L6.4 6.91L6.52 7.09L6.65 7.26L6.77 7.43L6.9 7.6L7.04 7.76L7.18 7.92L7.32 8.08L7.46 8.24L7.61 8.39L7.76 8.54L7.92 8.68L8.08 8.82L8.24 8.96L8.4 9.1L8.57 9.23L8.74 9.35L8.91 9.48L9.09 9.6L9.27 9.71L9.45 9.82L9.63 9.93L9.83 10.01L10.15 9.85L10.55 9.49L10.98 9.02L11.38 8.56L11.7 8.23L11.9 8.14L12.04 8.19L12.19 8.24L12.34 8.29L12.49 8.33L12.64 8.37L12.79 8.41L12.94 8.45L13.09 8.48L13.24 8.5L13.39 8.53L13.55 8.55Z" fill="url(#layer0)"/>
13 <path d="M12.13 13.15L12.71 13.23L12.45 13.19L12.2 13.14L11.94 13.09L11.69 13.03L11.44 12.97L11.19 12.9L10.94 12.83L10.69 12.75L10.45 12.66L10.2 12.57L9.96 12.48L9.72 12.38L9.48 12.27L9.25 12.16L9.02 12.04L8.79 11.92L8.56 11.8L8.34 11.66L8.12 11.53L7.9 11.39L7.68 11.24L7.47 11.09L7.26 10.93L7.06 10.77L6.86 10.61L6.66 10.44L6.47 10.27L6.28 10.09L6.09 9.91L5.91 9.72L5.73 9.53L5.56 9.34L5.39 9.14L5.23 8.94L5.07 8.74L4.91 8.53L4.76 8.32L4.61 8.1L4.47 7.88L4.34 7.66L4.2 7.44L4.08 7.21L3.96 6.98L3.84 6.75L3.73 6.52L3.62 6.28L3.52 6.04L3.43 5.8L3.34 5.55L3.25 5.31L3.17 5.06L3.1 4.81L3.03 4.56L2.97 4.31L2.91 4.06L2.86 3.8L2.81 3.55L2.77 3.29L2.85 3.87L6.72 3.4L6.63 2.73L6.66 2.9L6.69 3.07L6.72 3.24L6.76 3.41L6.8 3.58L6.85 3.75L6.9 3.91L6.95 4.08L7.01 4.24L6.95 4.07L7.06 3.81L6.87 3.99L6.41 4.38L5.91 4.85L5.43 5.37L5.06 6.14L5.31 6.75L5.45 6.99L5.57 7.19L5.7 7.38L5.83 7.57L5.96 7.76L6.1 7.95L6.24 8.13L6.39 8.31L6.54 8.48L6.69 8.66L6.85 8.82L7.01 8.99L7.18 9.15L7.34 9.31L7.52 9.46L7.69 9.61L7.87 9.76L8.05 9.9L8.24 10.04L8.43 10.17L8.62 10.3L8.81 10.43L9.01 10.55L9.25 10.69L9.86 10.94L10.63 10.57L11.15 10.09L11.62 9.59L12.01 9.13L12.19 8.94L11.93 9.05L11.76 8.99L11.92 9.05L12.09 9.1L12.25 9.15L12.42 9.2L12.59 9.24L12.76 9.28L12.93 9.31L13.1 9.34L13.27 9.37L12.6 9.28Z" fill="url(#layer1)"/>
14</svg>
crates/snowbound/assets/tags/star.svg deleted-14
...@@ -1,14 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#c8a049"/>
5 <stop offset="1" stop-color="#a57a2c"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#fff4c4"/>
9 <stop offset="1" stop-color="#f1c35a"/>
10 </linearGradient>
11 </defs>
12 <path d="M8 1.35L9.94 6.08L15.04 6.46L11.14 9.77L12.35 14.74L8 12.05L3.65 14.74L4.86 9.77L0.96 6.46L6.06 6.08Z" fill="url(#layer0)"/>
13 <path d="M8 3.72L9.32 6.94L12.78 7.2L10.13 9.44L10.96 12.82L8 10.99L5.04 12.82L5.87 9.44L3.22 7.2L6.68 6.94Z" fill="url(#layer1)"/>
14</svg>
crates/snowbound/src/art.rs-5
...@@ -39,10 +39,5 @@ pub const UNDO: &[&str] = art!("icons/undo");...@@ -39,10 +39,5 @@ pub const UNDO: &[&str] = art!("icons/undo");
39pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");39pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
40pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");40pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
4141
42pub const TAG_STAR: &[&str] = art!("tags/star");
43pub const TAG_REMEMBER: &[&str] = art!("tags/remember");42pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
44pub const TAG_DEFINITION: &[&str] = art!("tags/definition");43pub const TAG_DEFINITION: &[&str] = art!("tags/definition");
45pub const TAG_HIGHLIGHT: &[&str] = art!("tags/highlight");
46pub const TAG_CONTACT: &[&str] = art!("tags/contact");
47pub const TAG_ADDRESS: &[&str] = art!("tags/address");
48pub const TAG_PHONE: &[&str] = art!("tags/phone");
crates/snowbound/src/main.rs+13-13
...@@ -1076,27 +1076,27 @@ impl State {...@@ -1076,27 +1076,27 @@ impl State {
1076 });1076 });
1077 });1077 });
1078 divider(ui, "text", theme);1078 divider(ui, "text", theme);
1079 let tags: [(&[&str], Option<NoteTag>); 9] = [1079 let tags: [(&[&str], NoteTag); 9] = [
1080 (1080 (
1081 tag_sources(TagIcon::CheckBox { checked: false }),1081 tag_sources(TagIcon::CheckBox { checked: false }),
1082 Some(NoteTag::ToDo),1082 NoteTag::ToDo,
1083 ),1083 ),
1084 (art::TAG_STAR, None),1084 (tag_sources(TagIcon::Star), NoteTag::Important),
1085 (tag_sources(TagIcon::Question), Some(NoteTag::Question)),1085 (tag_sources(TagIcon::Question), NoteTag::Question),
1086 (art::TAG_REMEMBER, None),1086 (art::TAG_REMEMBER, NoteTag::RememberForLater),
1087 (art::TAG_DEFINITION, None),1087 (art::TAG_DEFINITION, NoteTag::Definition),
1088 (art::TAG_HIGHLIGHT, None),1088 (tag_sources(TagIcon::Highlight), NoteTag::Highlight),
1089 (art::TAG_CONTACT, None),1089 (tag_sources(TagIcon::Contact), NoteTag::Contact),
1090 (art::TAG_ADDRESS, None),1090 (tag_sources(TagIcon::Address), NoteTag::Address),
1091 (art::TAG_PHONE, None),1091 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),
1092 ];1092 ];
1093 group(ui, "tags", |ui| {1093 group(ui, "tags", |ui| {
1094 for (index, tags) in tags.chunks(5).enumerate() {1094 for (index, tags) in tags.chunks(5).enumerate() {
1095 row(ui, index, |ui| {1095 row(ui, index, |ui| {
1096 for (column, (icon, tag)) in tags.iter().enumerate() {1096 for (column, (icon, tag)) in tags.iter().enumerate() {
1097 let lit = tag.is_some_and(|tag| state.tags.contains(&tag));1097 let lit = state.tags.contains(tag);
1098 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {1098 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {
1099 command = tag.map(Formatting::Tag);1099 command = Some(Formatting::Tag(*tag));
1100 }1100 }
1101 }1101 }
1102 if index == 1 {1102 if index == 1 {
...@@ -1448,7 +1448,7 @@ impl State {...@@ -1448,7 +1448,7 @@ impl State {
1448 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,1448 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
1449 Command::Page(Request::Paste) => {1449 Command::Page(Request::Paste) => {
1450 let text = self.clipboard.get_text()?;1450 let text = self.clipboard.get_text()?;
1451 let response = self.view.commit_text(text)?;1451 let response = self.view.paste(&text)?;
1452 self.respond(response);1452 self.respond(response);
1453 }1453 }
1454 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),1454 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),