diff --git a/AGENTS.md b/AGENTS.md
index cf23b190baa7f976ebe28de8ad2bda5cac609719..94499d19796ff8e61f1c965257410940ac8b8ab7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,8 +4,9 @@ Snowbound is a modern remake of OneNote 2010 that stays fully interoperable
with it. Notes are rich text on a free canvas, stored in OneNote's own `.one`
files, so Snowbound and OneNote 2010 can open the same notebook side by side.
A notebook is just a folder. Put it on an SMB share and several people can
-edit it together from either app, with no cloud service involved. Snowbound
-runs on macOS and Linux, and has an iOS app built around the same core.
+edit it together from either app, with no cloud service involved. With headless
+crates implementing the file format and a generic canvas renderer made with a
+novel UI kit, each new platform port is extremely lightweight.
## Crates
@@ -39,7 +40,6 @@ these pieces meet.
in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done
when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as
intended ([testing](arc/testing.md)).
-- **Jujutsu, not git.** The repository is managed with `jj`. Don't run `git`.
- **Disposable copies only.** Never point the app, a replay script or a lab
harness at an original notebook.
@@ -48,15 +48,15 @@ these pieces meet.
A `.one` file is a revision store. Every client, OneNote included, commits by
appending a revision and then rewriting the 1024-byte header, which makes the
header plus the file length a cheap *stamp* of the committed state. Snowbound
-queues edits as ops in a local SQLite replica. A background thread polls the
-stamp. If the stamp is unchanged, it publishes the queued batch as one
+queues edits as ops in a local SQLite replica, then a background thread polls
+the stamp. If the stamp is unchanged, it publishes the queued batch as one
appended revision. If it has changed, it reads the file once and replays the
-queue on top. Where an op can't merge, the result is what OneNote 2010 makes:
-a read-only conflict page under the page. OneNote coordinates writers through
-share modes and one-byte locks on the section file itself, and macOS's
-`smbfs` can't reproduce those, so Snowbound carries its own SMB client that
-takes exactly OneNote's locks. Offline is just a sync step that fails: the
-queue waits and publishes later. [More in the sync essay.](arc/sync.md)
+queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a
+read-only conflict page under the page. OneNote coordinates writers through
+share modes and one-byte locks on the section file itself. These are special SMB
+protocols that only Windows supports, so Snowbound carries its own SMB client
+that can properly issue them. Offline is just a sync step that fails: the queue
+waits and publishes later. [More in the sync essay.](arc/sync.md)
## Architecture notes
@@ -90,3 +90,10 @@ python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowb
The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications.
Local copies, design history and raw lab evidence are kept out of version
control.
+
+External contributors (not agents, will be denied unless a human writes to me)
+are welcome to email `git@paperclover.net` to gain access to the testing VM
+images for their agents. Alternatively, a custom image can be configured with a
+licensed copy of Windows and OneNote. By connecting `tools/w7/mcp_*.py`, the
+agent can interactively play around in with the actual prior art, in addition
+to running tests.
diff --git a/Cargo.lock b/Cargo.lock
index 1170052ad8d1e4fc2b0fa88aa340c3caff4be1f9..2f0ed2a7353046ddd92cc23e10675b351d8b3461 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2953,8 +2953,6 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"ab_glyph",
"log",
@@ -3247,6 +3245,7 @@ dependencies = [
"parley",
"png",
"pollster",
+ "sctk-adwaita",
"serde",
"serde_json",
"smithay-clipboard",
diff --git a/Cargo.toml b/Cargo.toml
index f0ff815adc8d9261dc0b92ca78a68e1e1a346c50..e73f80dd513311e4a5edbfbd222119018ecbd90c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
members = ["crates/*"]
-exclude = ["fuzz"]
+exclude = ["fuzz", "vendor"]
resolver = "3"
[workspace.dependencies]
@@ -10,6 +10,9 @@ parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21
fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" }
wgpu = "30.0.1"
+[patch.crates-io]
+sctk-adwaita = { path = "vendor/sctk-adwaita" }
+
# Shaping, rasterization and GPU submission stay optimised in debug builds, where
# unoptimised dependencies alone drop frames.
[profile.dev.package."*"]
diff --git a/arc/canvas.md b/arc/canvas.md
index 8034a7146b4d51d3207b105d61f38bdb93b2eac6..9258569391cb92a012cd45b43b950457f97af6ca 100644
--- a/arc/canvas.md
+++ b/arc/canvas.md
@@ -26,8 +26,10 @@ host translates platform events in; carries out the Reques
A host feeds `PageView` pointer, key and text-input events. It gets back a
`Response` saying whether the page, the selection or only the view changed,
plus the occasional `Request` the page can't do itself: show a date picker,
-read the clipboard, open the character palette. Everything platform-specific
-stays on the host's side of that line.
+read the clipboard, open a link. Everything platform-specific stays on the
+host's side of that line, shortcuts included: the page takes editing keys, and
+the host's command table runs the chords for formatting, tags, zoom and the
+clipboard through the page's own methods.
## Editing emits ops
@@ -73,13 +75,15 @@ gives outline sizes, its PDF export gives exact line breaks, and screenshots
give placement. The comparators in `tools/canvas` (with the probes
`layout-probe` and `page-probe`) keep checking them.
-Equations draw in two dimensions from the tree `onestore::page::Math` parses.
-They are edited as OneNote's equation editor edits them: Alt+= starts one,
+Equations draw in two dimensions from the tree `onestore::page::Math` parses,
+each in a space kept in its line of text, so text and links around them keep
+their look and the line grows to hold them. They are edited as OneNote's equation editor edits them: Alt+= starts one,
typing is its linear format (UnicodeMath), and a space builds up what it ends.
Linear and Professional switch an equation between the forms, and OneNote
stores both. Links follow OneNote too: a typed URL links itself when a space or
Enter ends it, the Link dialog stores its address in a hidden field code before
-the label, and a click opens a link. Ink draws stroke by stroke in page
+the label, and a click or Enter opens a link. URL text shows as a link, as
+OneNote links it when it opens a page, without being stored as one. Ink draws stroke by stroke in page
coordinates. Page templates' background art is recreated as vector art and
recognised by the stored picture's hash. OneNote's bitmaps aren't shipped.
diff --git a/arc/platforms.md b/arc/platforms.md
index aff2ff1b4e35a2be0a9618c7c13b420444375811..56cec3eb6d03eacb623f81be9cd1937e41f62564 100644
--- a/arc/platforms.md
+++ b/arc/platforms.md
@@ -23,6 +23,9 @@ page inside it. `snowbound` picks a platform module at compile time
in the crate is shared: library and settings, the sidebar, menus, page and
section management, templates, and screenshot and replay support.
Accessibility for the page goes through AccessKit's winit adapter on both.
+`commands.rs` is the one table of commands: each one's title, its chords on
+macOS and elsewhere, when it is enabled or checked, and what it does. The
+keyboard, the toolbar and the macOS menu bar all run commands from it.
### macOS
@@ -40,6 +43,10 @@ Accessibility for the page goes through AccessKit's winit adapter on both.
- A notebook on a mounted SMB share is opened through the embedded SMB client,
signed in with the password the keychain keeps for that mount (see
[sync](sync.md) for why the mount itself isn't enough).
+- The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are
+ the command table's, validated from the statuses each frame publishes, and
+ their key equivalents are the table's chords, so AppKit takes a chord the
+ menu enables before winit sees the key. Linux has no menu bar.
- `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`.
### Linux
diff --git a/arc/ui.md b/arc/ui.md
index c463fe2756e7788b24be651ca79ac85a65bf9ecc..94eda4a8e9373bcac764ed36f11110d42cd21d93 100644
--- a/arc/ui.md
+++ b/arc/ui.md
@@ -89,7 +89,7 @@ input-method events that land on it, in order. The host hands them to
frame. The page's scrollbars are ordinary `ui` widgets; the canvas only
reports its scroll bounds.
-`ui::popup` builds menus, filterable lists, a colour grid and a fuzzy command
+`ui::popup` builds menus, filterable lists, a colour grid, galleries and a fuzzy command
palette on a popup layer that takes input above everything else. `ui::shell`
has the OneNote-specific controls: section tabs and compact toolbar buttons.
The kit doesn't know what a notebook is. `snowbound` assembles the window from
diff --git a/crates/canvas/assets/tags/flag.svg b/crates/canvas/assets/tags/flag.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7219fb33baf9f7dd45400dc26be74ead45ac4751
--- /dev/null
+++ b/crates/canvas/assets/tags/flag.svg
@@ -0,0 +1,10 @@
+
diff --git a/crates/canvas/assets/tags/highlight.svg b/crates/canvas/assets/tags/highlight.svg
index 9922e577f6a695a0c1b4597805fc654a293811c4..06ec61120d95c8b08907bc3265ffba8a0a672c70 100644
--- a/crates/canvas/assets/tags/highlight.svg
+++ b/crates/canvas/assets/tags/highlight.svg
@@ -21,9 +21,9 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/crates/canvas/assets/tags/question.svg b/crates/canvas/assets/tags/question.svg
index 18ab658b3b8bf9ea3cec4fd1d319402eef9258f4..37359c57129ad052d34037233c28c44ffe6c794d 100644
--- a/crates/canvas/assets/tags/question.svg
+++ b/crates/canvas/assets/tags/question.svg
@@ -5,5 +5,5 @@
-
+
diff --git a/crates/canvas/assets/tags/tag.svg b/crates/canvas/assets/tags/tag.svg
new file mode 100644
index 0000000000000000000000000000000000000000..58ab578943c2b62c841ac754582ff033417045d0
--- /dev/null
+++ b/crates/canvas/assets/tags/tag.svg
@@ -0,0 +1,10 @@
+
diff --git a/crates/canvas/src/conflict.rs b/crates/canvas/src/conflict.rs
index 9ae6e5e70479ea7e8367f49acf570cc607f19ec5..d3186859dc696e4018a036a4c179674a394b631d 100644
--- a/crates/canvas/src/conflict.rs
+++ b/crates/canvas/src/conflict.rs
@@ -1,4 +1,4 @@
-//! Conflict pages as OneNote shows them.
+//! Conflict pages and page versions as OneNote shows them.
use onestore::{
ExGuid,
@@ -8,21 +8,72 @@ use onestore::{
/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
pub const CONFLICTING: u32 = 0xd6d6ff;
+/// OneNote's highlight for what a page version changed since the version before it, COLORREF.
+pub const CHANGED: u32 = 0xd6ffd6;
+
/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band
/// across each conflicting paragraph.
-pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
- fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {
+pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page {
+ banded(page, objects, CONFLICTING)
+}
+
+/// `version`, a page version, with the text it changed since `older`, the version before it,
+/// banded as OneNote shows it; all of it without one.
+pub fn changes(version: Page, older: Option<&Page>) -> Page {
+ let texts = |page: &Page| {
+ let mut texts = Vec::new();
+ each_text(page, &mut |id, text| texts.push((id, text.to_owned())));
+ texts
+ };
+ let before = older.map(texts).unwrap_or_default();
+ let changed: Vec = texts(&version)
+ .into_iter()
+ .filter(|text| !before.contains(text))
+ .map(|(id, _)| id)
+ .collect();
+ banded(version, &changed, CHANGED)
+}
+
+fn each_text(page: &Page, f: &mut impl FnMut(ExGuid, &str)) {
+ fn walk(paragraphs: &[PageParagraph], f: &mut impl FnMut(ExGuid, &str)) {
+ for paragraph in paragraphs {
+ match ¶graph.content {
+ ParagraphContent::Table(table) => {
+ for cell in table.rows.iter().flat_map(|row| &row.cells) {
+ walk(&cell.paragraphs, f);
+ }
+ }
+ ParagraphContent::Text(text) => f(text.id, text.text.text()),
+ _ => {}
+ }
+ }
+ }
+ for object in &page.objects {
+ match object {
+ PageObject::Outline(outline) => walk(&outline.paragraphs, f),
+ PageObject::Title(title) => {
+ for outline in &title.outlines {
+ walk(&outline.paragraphs, f);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page {
+ fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) {
for paragraph in paragraphs {
match &mut paragraph.content {
ParagraphContent::Table(table) => {
for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
- mark(&mut cell.paragraphs, objects);
+ mark(&mut cell.paragraphs, objects, color);
}
}
ParagraphContent::Text(text)
if objects.contains(¶graph.id) || objects.contains(&text.id) =>
{
- paragraph.format.highlight = Some(CONFLICTING);
+ paragraph.format.highlight = Some(color);
}
_ => {}
}
@@ -30,10 +81,10 @@ pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
}
for object in &mut page.objects {
match object {
- PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),
+ PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects, color),
PageObject::Title(title) => {
for outline in &mut title.outlines {
- mark(&mut outline.paragraphs, objects);
+ mark(&mut outline.paragraphs, objects, color);
}
}
_ => {}
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index db94e28d9a1bda652521b5b6401adf03a2e4a364..1687d88dd6d9b83cc9b77271854c2b65486a8362 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -248,28 +248,27 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result {
}
/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
-/// an equation or embedded object at the seam or carries one to another paragraph: their run
-/// data belongs to the whole paragraph, and OneNote has not been seen dividing them.
+/// an embedded object at the seam or carries one to another paragraph, or joins inside an
+/// equation: the object's run data belongs to its paragraph, and an equation divides only at
+/// its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).
fn moves_object(
first: &Paragraph,
start: u32,
last: &Paragraph,
end: u32,
) -> Result {
- let object = |text: &Paragraph, span: usize| {
- let format = &text.spans()[span].format;
- [format.math, format.embedded_object].contains(&Some(true))
+ let math = |text: &Paragraph, offset: u32| {
+ text.format_at(offset)
+ .is_ok_and(|format| format.math == Some(true))
};
let (before, after) = (first.byte_offset(start)?, last.byte_offset(end)?);
+ let following = last.text()[after..]
+ .chars()
+ .next()
+ .map(|c| end + c.len_utf16() as u32);
Ok(first.text()[..before].ends_with('\u{fffc}')
|| last.text()[after..].contains('\u{fffc}')
- || before > 0
- && first
- .spans()
- .iter()
- .position(|span| before - 1 < span.end)
- .is_some_and(|span| object(first, span))
- || (0..last.spans().len()).any(|span| last.spans()[span].end > after && object(last, span)))
+ || start > 0 && math(first, start) && following.is_some_and(|next| math(last, next)))
}
/// Paragraph positions of each node's first text leaf, counting from `first`.
@@ -281,6 +280,50 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator-
})
}
+fn enclosing(
+ nodes: &[PageParagraph],
+ first: usize,
+ paragraph: usize,
+ ranges: &mut Vec>,
+) {
+ let count = |nodes: &[PageParagraph]| leaves(nodes, None).count();
+ let Some((index, start)) = starts(nodes, first)
+ .enumerate()
+ .take_while(|(_, start)| *start <= paragraph)
+ .last()
+ else {
+ return;
+ };
+ let ParagraphContent::Table(table) = &nodes[index].content else {
+ let end = subtree_end(nodes, index);
+ if end > index + 1 {
+ ranges.push(start..start + count(&nodes[index..end]));
+ }
+ return;
+ };
+ let mut cell_start = start;
+ for row in &table.rows {
+ let row_start = cell_start;
+ let mut holder = None;
+ for cell in &row.cells {
+ let cell_end = cell_start + count(&cell.paragraphs);
+ if (cell_start..cell_end).contains(¶graph) {
+ holder = Some((cell, cell_start..cell_end));
+ }
+ cell_start = cell_end;
+ }
+ if let Some((cell, range)) = holder {
+ enclosing(&cell.paragraphs, range.start, paragraph, ranges);
+ ranges.extend([
+ range,
+ row_start..cell_start,
+ start..start + count(&nodes[index..=index]),
+ ]);
+ return;
+ }
+ }
+}
+
/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
/// contiguously; a paragraph only an outline group indents has no parent to descend from.
pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
@@ -551,6 +594,14 @@ impl TextDocument {
}
}
+ /// Text leaf ranges holding `paragraph`, innermost first: its subtree when it has
+ /// descendants, then the cell, row and table of each table around it.
+ pub(crate) fn enclosing(&self, paragraph: usize) -> Vec> {
+ let mut ranges = Vec::new();
+ enclosing(&self.nodes, 0, paragraph, &mut ranges);
+ ranges
+ }
+
/// The root node holding a table cell.
pub(crate) fn root(&self, cell: ExGuid) -> Result {
self.tables
diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs
index 053638bd4769e66fcebaf31b556316422f0238fa..4cf3c269910a0ae0595d09c3f59b8d517e48f8b9 100644
--- a/crates/canvas/src/editor.rs
+++ b/crates/canvas/src/editor.rs
@@ -24,13 +24,18 @@ use std::{
pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
+mod equation;
#[cfg(test)]
mod evidence;
mod format;
+mod link;
mod ops;
pub(crate) mod page;
mod table;
-pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};
+pub use format::{
+ Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
+};
+pub use link::{Link, shown_urls};
pub use page::ReadOnlyObject;
#[derive(Debug)]
@@ -395,6 +400,19 @@ impl TextOutline {
.unwrap()
}
+ /// The start of the paragraph after visible paragraph `index` when it follows in the same
+ /// container, where a whole-paragraph selection ends so that deleting it takes the break.
+ fn next_sibling_start(&self, index: usize) -> Option {
+ let next =
+ (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;
+ let (container, local, _) = self.document.leaf(self.source_index(index))?;
+ let (next_container, next_local, _) = self.document.leaf(next)?;
+ (container == next_container && next_local == local + 1).then_some(TextPosition {
+ paragraph: next,
+ offset: 0,
+ })
+ }
+
pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
Ok(&self.shaped.paragraphs[self.visible_index(source)?])
}
@@ -2006,6 +2024,50 @@ impl CanvasEditor {
)
}
+ /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens
+ /// it: the paragraph, its subtree, each enclosing table cell, row and table, then the outline.
+ pub fn widen_selection(&mut self) -> Result<(), EditError> {
+ let outline = self.active_outline();
+ let [anchor, focus] = outline.selection.positions;
+ let (start, end) = (anchor.min(focus), anchor.max(focus));
+ let last_shown = |range: Range| {
+ let paragraph = range
+ .rev()
+ .find(|¶graph| outline.visible_index(paragraph).is_ok())?;
+ let text = outline.document.paragraph(paragraph)?;
+ Some(TextPosition {
+ paragraph,
+ offset: text.utf16_offset(text.text().len()).ok()?,
+ })
+ };
+ let first = |paragraph| TextPosition {
+ paragraph,
+ offset: 0,
+ };
+ let own = outline
+ .next_sibling_start(outline.visible_index(start.paragraph)?)
+ .or_else(|| last_shown(start.paragraph..start.paragraph + 1));
+ let units = std::iter::once((first(start.paragraph), own))
+ .chain(
+ outline
+ .document
+ .enclosing(start.paragraph)
+ .into_iter()
+ .map(|range| (first(range.start), last_shown(range))),
+ )
+ .chain([(
+ first(0),
+ last_shown(0..outline.document.paragraphs().count()),
+ )]);
+ match units
+ .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))
+ .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end])
+ {
+ Some(unit) => self.select(unit.into()),
+ None => Ok(()),
+ }
+ }
+
pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;
if extend {
@@ -2046,20 +2108,10 @@ impl CanvasEditor {
selection.affinities[slot] = cursor.affinity();
}
if unit == SelectionUnit::Paragraph
- && index + 1 < self.active_outline().shaped.paragraphs.len()
+ && let Some(next) = self.active_outline().next_sibling_start(index)
{
- let document = &self.active_outline().document;
- let source = selection.positions[0].paragraph;
- let next = self.active_outline().source_index(index + 1);
- let (container, local, _) = document.leaf(source).unwrap();
- let (next_container, next_local, _) = document.leaf(next).unwrap();
- if container == next_container && next_local == local + 1 {
- selection.positions[1] = TextPosition {
- paragraph: next,
- offset: 0,
- };
- selection.affinities[1] = Affinity::Downstream;
- }
+ selection.positions[1] = next;
+ selection.affinities[1] = Affinity::Downstream;
}
Ok(selection)
}
@@ -2093,6 +2145,9 @@ impl CanvasEditor {
extend: bool,
) -> Result<(), EditorError> {
self.finish_composition();
+ if !extend && movement == Movement::Right && self.leave_equation()? {
+ return Ok(());
+ }
let [anchor, focus] = self.active_outline().selection.positions;
if !extend
&& self.caret_outline().is_some()
@@ -2557,7 +2612,9 @@ impl CanvasEditor {
)
}
+ /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
+ self.leave_link_code()?;
let start = self.active_outline().selection.positions[0]
.min(self.active_outline().selection.positions[1]);
let format = self.typing_format(start)?;
@@ -2565,7 +2622,12 @@ impl CanvasEditor {
.split('\n')
.map(|line| Paragraph::new(line.to_owned(), format.clone()))
.collect();
- self.replace(engine, replacement)
+ self.replace(engine, replacement)?;
+ if matches!(text, " " | "\t") {
+ self.link_typed_url(engine, start)?;
+ self.build_typed_equation(engine, start)?;
+ }
+ Ok(())
}
/// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
@@ -2634,7 +2696,13 @@ impl CanvasEditor {
fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
let [anchor, focus] = self.active_outline().selection.positions;
let (start, end) = (anchor.min(focus), anchor.max(focus));
- let format = self.typing_format(start)?;
+ let mut format = self.typing_format(start)?;
+ // A link or equation ends with its paragraph.
+ link::unlinked(&mut format);
+ if format.math == Some(true) {
+ let text = self.active_outline().document.paragraph(start.paragraph);
+ format = equation::text_after(text.ok_or(EditError::InvalidRange)?);
+ }
let document = &self.active_outline().document;
let mut edit = document.replace(
start..end,
@@ -2746,6 +2814,75 @@ impl CanvasEditor {
Ok(true)
}
+ /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the
+ /// sibling above or below with its children. Does nothing past the first or last sibling.
+ pub fn move_paragraphs(
+ &mut self,
+ engine: &mut TextEngine,
+ up: bool,
+ ) -> Result {
+ use crate::document::{previous_sibling, subtree_end};
+ let outline = self.active_outline();
+ if outline.title {
+ return Ok(false);
+ }
+ let selection = outline.selection;
+ let [anchor, focus] = selection.positions;
+ let document = &outline.document;
+ let (container, first, _) = document
+ .leaf(anchor.min(focus).paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let (end_container, last, _) = document
+ .leaf(anchor.max(focus).paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ if container != end_container {
+ return Ok(false);
+ }
+ let nodes = document.container(container)?;
+ let parent = nodes[first].parent;
+ let mut end = first;
+ while end <= last {
+ if nodes[end].parent != parent {
+ return Ok(false);
+ }
+ end = subtree_end(nodes, end);
+ }
+ let (range, [moved, other]) = if up {
+ let Some(sibling) = previous_sibling(nodes, first, &BTreeSet::new()) else {
+ return Ok(false);
+ };
+ (sibling..end, [first..end, sibling..first])
+ } else {
+ if nodes.get(end).is_none_or(|node| node.parent != parent) {
+ return Ok(false);
+ }
+ let next = subtree_end(nodes, end);
+ (first..next, [first..end, end..next])
+ };
+ let passed = leaves(&nodes[other.clone()], None).count();
+ let replacement = if up {
+ [&nodes[moved], &nodes[other]].concat()
+ } else {
+ [&nodes[other], &nodes[moved]].concat()
+ };
+ let mut moved = selection;
+ for position in &mut moved.positions {
+ position.paragraph = if up {
+ position.paragraph - passed
+ } else {
+ position.paragraph + passed
+ };
+ }
+ let edit = DocumentEdit {
+ columns: BTreeMap::new(),
+ container,
+ range,
+ replacement,
+ };
+ self.commit(engine, edit, moved)?;
+ Ok(true)
+ }
+
/// Deletes the selection or the character beside the caret. Backspace at a paragraph's start
/// first removes its list, then outdents it, then joins it to the paragraph above, whose
/// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
@@ -8872,4 +9009,176 @@ mod tests {
assert!(editor.undo.is_empty());
assert_eq!(editor.take_ops().unwrap(), []);
}
+
+ /// Alpha, Parent > Child > Grand, a table [[A1 A2 | B1], [C1 | D1]], Omega: text leaves 0..=9.
+ fn select_more_editor(engine: &mut TextEngine, collapsed: bool) -> CanvasEditor {
+ use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
+ let text = |text: &str| {
+ crate::document::node(
+ Paragraph::new(text.into(), Format::default()),
+ Format::default(),
+ )
+ .unwrap()
+ };
+ let child = |label: &str, parent: &PageParagraph| PageParagraph {
+ parent: Some(parent.id),
+ level: parent.level + 1,
+ ..text(label)
+ };
+ let parent = PageParagraph {
+ collapsed,
+ ..text("Parent")
+ };
+ let middle = child("Child", &parent);
+ let grand = child("Grand", &middle);
+ let mut table = text("");
+ table.content = ParagraphContent::Table(Table {
+ id: onestore::page::text::new_id().unwrap(),
+ columns: vec![
+ TableColumn {
+ width: 72.0,
+ locked: true,
+ };
+ 2
+ ],
+ rows: [[&["A1", "A2"][..], &["B1"]], [&["C1"], &["D1"]]]
+ .into_iter()
+ .map(|cells| TableRow {
+ id: onestore::page::text::new_id().unwrap(),
+ cells: cells
+ .into_iter()
+ .map(|texts| TableCell {
+ id: onestore::page::text::new_id().unwrap(),
+ layout: Default::default(),
+ indents: vec![18.0, 0.0, 27.0],
+ shading: None,
+ paragraphs: texts.iter().map(|t| text(t)).collect(),
+ unsupported: Vec::new(),
+ })
+ .collect(),
+ })
+ .collect(),
+ borders: Some(true),
+ layout: Default::default(),
+ tags: Vec::new(),
+ });
+ let nodes = vec![text("Alpha"), parent, middle, grand, table, text("Omega")];
+ CanvasEditor::new(engine, TextDocument::from_nodes(nodes).unwrap(), 300.0).unwrap()
+ }
+
+ /// The selections successive `widen_selection` calls give from `start` to `end`.
+ fn widenings(
+ editor: &mut CanvasEditor,
+ start: (usize, u32),
+ end: (usize, u32),
+ ) -> Vec<[(usize, u32); 2]> {
+ let position = |(paragraph, offset)| TextPosition { paragraph, offset };
+ editor
+ .select([position(start), position(end)].into())
+ .unwrap();
+ (0..6)
+ .map(|_| {
+ editor.widen_selection().unwrap();
+ editor
+ .selection()
+ .positions
+ .map(|position| (position.paragraph, position.offset))
+ })
+ .collect()
+ }
+
+ #[test]
+ fn select_all_widens_as_onenote_does() {
+ let mut engine = TextEngine::default();
+ let mut editor = select_more_editor(&mut engine, false);
+ let outline = [(0, 0), (9, 5)];
+ let steps = |steps: &[[(usize, u32); 2]]| {
+ let mut steps = steps.to_vec();
+ steps.resize(6, outline);
+ steps
+ };
+ // A paragraph takes its break when a sibling follows, then the outline.
+ assert_eq!(
+ widenings(&mut editor, (0, 2), (0, 2)),
+ steps(&[[(0, 0), (1, 0)]])
+ );
+ // A parent, then its subtree, then the outline.
+ assert_eq!(
+ widenings(&mut editor, (1, 2), (1, 2)),
+ steps(&[[(1, 0), (2, 0)], [(1, 0), (3, 5)]])
+ );
+ // A child widens to its own subtree, never its parent's.
+ assert_eq!(
+ widenings(&mut editor, (2, 1), (2, 1)),
+ steps(&[[(2, 0), (3, 0)], [(2, 0), (3, 5)]])
+ );
+ assert_eq!(
+ widenings(&mut editor, (3, 1), (3, 1)),
+ steps(&[[(3, 0), (3, 5)]])
+ );
+ // In a table: paragraph, cell, row, table.
+ assert_eq!(
+ widenings(&mut editor, (4, 1), (4, 1)),
+ steps(&[
+ [(4, 0), (5, 0)],
+ [(4, 0), (5, 2)],
+ [(4, 0), (6, 2)],
+ [(4, 0), (8, 2)],
+ ])
+ );
+ // A cell holding one paragraph adds nothing to it.
+ assert_eq!(
+ widenings(&mut editor, (6, 1), (6, 1)),
+ steps(&[[(6, 0), (6, 2)], [(4, 0), (6, 2)], [(4, 0), (8, 2)]])
+ );
+ assert_eq!(
+ widenings(&mut editor, (9, 1), (9, 1)),
+ steps(&[[(9, 0), (9, 5)]])
+ );
+ }
+
+ #[test]
+ fn select_all_starts_from_the_current_selection() {
+ let mut engine = TextEngine::default();
+ let mut editor = select_more_editor(&mut engine, false);
+ let first = |editor: &mut CanvasEditor, start, end| widenings(editor, start, end)[0];
+ // Part or all of a paragraph's text widens to the paragraph.
+ assert_eq!(first(&mut editor, (0, 1), (0, 3)), [(0, 0), (1, 0)]);
+ assert_eq!(first(&mut editor, (0, 0), (0, 5)), [(0, 0), (1, 0)]);
+ assert_eq!(first(&mut editor, (0, 3), (0, 1)), [(0, 0), (1, 0)]);
+ // Across paragraphs, the outline; across cells of a row, the row.
+ assert_eq!(first(&mut editor, (0, 1), (1, 1)), [(0, 0), (9, 5)]);
+ assert_eq!(first(&mut editor, (5, 1), (6, 1)), [(4, 0), (6, 2)]);
+ assert_eq!(first(&mut editor, (6, 1), (7, 1)), [(4, 0), (8, 2)]);
+ // A caret moved after widening starts over at its paragraph.
+ assert_eq!(first(&mut editor, (9, 1), (9, 1)), [(9, 0), (9, 5)]);
+ // Selection only: nothing to undo or store.
+ assert!(editor.undo.is_empty());
+ assert_eq!(editor.take_ops().unwrap(), []);
+ }
+
+ #[test]
+ fn select_all_skips_the_hidden_children_of_a_collapsed_parent() {
+ let mut engine = TextEngine::default();
+ let mut editor = select_more_editor(&mut engine, true);
+ assert_eq!(
+ widenings(&mut editor, (1, 2), (1, 2))[..2],
+ [[(1, 0), (1, 6)], [(0, 0), (9, 5)]]
+ );
+ }
+
+ #[test]
+ fn select_all_in_a_single_paragraph_stops_at_the_paragraph() {
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::new(
+ &mut engine,
+ TextDocument::new(vec![Paragraph::new("Title".into(), Format::default())]).unwrap(),
+ 180.0,
+ )
+ .unwrap();
+ assert_eq!(
+ widenings(&mut editor, (0, 5), (0, 5)),
+ [[(0, 0), (0, 5)]; 6]
+ );
+ }
}
diff --git a/crates/canvas/src/editor/equation.rs b/crates/canvas/src/editor/equation.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e0e20df4a641dd72520643ad87cc60934fa36323
--- /dev/null
+++ b/crates/canvas/src/editor/equation.rs
@@ -0,0 +1,557 @@
+//! Equations as OneNote 2010's equation editor edits them (`corpus/math-edit/native-linear`):
+//! Alt+= starts an equation at the caret or makes one of the selected text, text typed into
+//! an equation is its linear format until a space builds it up, and Linear and Professional
+//! switch an equation between the forms, both of which OneNote stores.
+
+use super::link::format_after;
+use super::*;
+use onestore::page::Math;
+
+const OBJECT_START: char = '\u{fdd0}';
+const ARGUMENT_SEPARATOR: char = '\u{fdee}';
+const OBJECT_END: char = '\u{fdef}';
+
+/// The run of math in `text` that UTF-16 `offset` lies in or touches.
+pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option> {
+ let (mut byte, mut unit) = (0, 0);
+ let mut current: Option> = None;
+ for span in text.spans() {
+ let end = unit + text.text()[byte..span.end].encode_utf16().count() as u32;
+ if span.format.math == Some(true) {
+ current = Some(
+ current
+ .filter(|zone| zone.end == unit)
+ .map_or(unit, |zone| zone.start)..end,
+ );
+ } else if current
+ .as_ref()
+ .is_some_and(|zone| zone.start <= offset && offset <= zone.end)
+ {
+ return current;
+ } else {
+ current = None;
+ }
+ (byte, unit) = (span.end, end);
+ }
+ current.filter(|zone| zone.start <= offset && offset <= zone.end)
+}
+
+/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
+/// its first run's without the equation editor's.
+pub(super) fn text_after(text: &Paragraph) -> Format {
+ let mut format = text
+ .spans()
+ .iter()
+ .rev()
+ .find(|span| span.format.math != Some(true))
+ .map_or_else(
+ || text.spans()[0].format.clone(),
+ |span| span.format.clone(),
+ );
+ format.math = format.math.map(|_| false);
+ format.embedded_object = None;
+ format.math_object = None;
+ if format.font.as_deref() == Some("Cambria Math") {
+ // OneNote's body font, where the paragraph has no text to take one from.
+ format.font = Some("Calibri".into());
+ format.italic = format.italic.map(|_| false);
+ // The writers' default, where the paragraph has no text to take a language from.
+ format.language = Some(0x409);
+ }
+ format
+}
+
+impl CanvasEditor {
+ /// The paragraph and math run at the caret.
+ fn caret_zone(&self) -> Option<(usize, Range)> {
+ let [anchor, focus] = self.active_outline().selection.positions;
+ let caret = anchor.min(focus);
+ let text = self.active_outline().document.paragraph(caret.paragraph)?;
+ Some((caret.paragraph, zone(text, caret.offset)?))
+ }
+
+ /// Whether the caret is in an equation, which Linear and Professional act on.
+ pub fn in_equation(&self) -> bool {
+ self.caret_zone().is_some()
+ }
+
+ /// Professional: builds the equation at the caret up from its linear text.
+ pub fn build_equation(&mut self, engine: &mut TextEngine) -> Result {
+ let Some((paragraph, zone)) = self.caret_zone() else {
+ return Ok(false);
+ };
+ self.rebuild(engine, paragraph, zone, None)
+ }
+
+ /// Linear: shows the equation at the caret in its linear format, as text to edit.
+ pub fn linear_equation(&mut self, engine: &mut TextEngine) -> Result {
+ let Some((paragraph, zone)) = self.caret_zone() else {
+ return Ok(false);
+ };
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let math = text.slice(zone.clone())?;
+ let Ok(nodes) = Math::parse(&math) else {
+ return Ok(false);
+ };
+ let Some(linear) = Math::linear_paragraph(&nodes, &math.spans()[0].format) else {
+ return Ok(false);
+ };
+ let end = zone.start + linear.utf16_offset(linear.text().len())?;
+ self.replace_zone(engine, paragraph, zone, linear, end)
+ }
+
+ /// Alt+=: the selected text becomes an equation, the equation at the caret builds up, or
+ /// what is typed next at the caret starts one.
+ pub fn insert_equation(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
+ let outline = self.active_outline();
+ let [anchor, focus] = outline.selection.positions;
+ let (start, end) = (anchor.min(focus), anchor.max(focus));
+ if start == end {
+ if self.caret_zone().is_some() {
+ self.build_equation(engine)?;
+ return Ok(());
+ }
+ let format = Math::format(&self.typing_format(start)?);
+ self.pending = Some((outline.id, start, format));
+ return Ok(());
+ }
+ if start.paragraph != end.paragraph {
+ return Err(EditError::UnsupportedContent.into());
+ }
+ let text = outline
+ .document
+ .paragraph(start.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let selected = text.slice(start.offset..end.offset)?;
+ let linear = selected.project()?.text().text().replace('\n', " ");
+ let base = format_after(text, start.offset)
+ .cloned()
+ .unwrap_or_default();
+ let math = Math::paragraph(&Math::from_linear(&linear), &base);
+ let caret = start.offset + math.utf16_offset(math.text().len())?;
+ self.replace_zone(
+ engine,
+ start.paragraph,
+ start.offset..end.offset,
+ math,
+ caret,
+ )?;
+ Ok(())
+ }
+
+ /// Builds the equation up after the space typed at `typed` ended part of it, as the
+ /// equation editor builds as it is typed. Only a caret at the end of the equation, or
+ /// only closing its objects before that end, builds; the caret stays as deep in the built
+ /// objects.
+ pub(super) fn build_typed_equation(
+ &mut self,
+ engine: &mut TextEngine,
+ typed: TextPosition,
+ ) -> Result<(), EditorError> {
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(typed.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ if format_after(text, typed.offset).is_none_or(|format| format.math != Some(true)) {
+ return Ok(());
+ }
+ let Some(zone) = zone(text, typed.offset) else {
+ return Ok(());
+ };
+ let caret = typed.offset + 1;
+ let after = &text.text()[text.byte_offset(caret)?..text.byte_offset(zone.end)?];
+ if !after.chars().all(|c| c == OBJECT_END) {
+ return Ok(());
+ }
+ let depth = after.chars().count() as u32;
+ self.rebuild(engine, typed.paragraph, zone, Some(depth))?;
+ Ok(())
+ }
+
+ /// Right at the end of an equation ending its paragraph leaves it: what is typed next is
+ /// text again.
+ pub(super) fn leave_equation(&mut self) -> Result {
+ let outline = self.active_outline();
+ let [anchor, focus] = outline.selection.positions;
+ let Some(text) = outline.document.paragraph(focus.paragraph) else {
+ return Ok(false);
+ };
+ let end = text.utf16_offset(text.text().len())?;
+ let math = self.typing_format(focus)?.math == Some(true);
+ if anchor != focus || focus.offset != end || !math {
+ return Ok(false);
+ }
+ self.pending = Some((outline.id, focus, text_after(text)));
+ Ok(true)
+ }
+
+ /// Rebuilds math run `zone` of paragraph `paragraph` from its linear text. With `depth`,
+ /// the caret goes that many objects before the end of the built run, and into an
+ /// argument left empty there; without, to the run's end.
+ fn rebuild(
+ &mut self,
+ engine: &mut TextEngine,
+ paragraph: usize,
+ zone: Range,
+ depth: Option,
+ ) -> Result {
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let math = text.slice(zone.clone())?;
+ let Some(linear) = Math::parse(&math)
+ .ok()
+ .and_then(|nodes| Math::linear(&nodes))
+ else {
+ return Ok(false);
+ };
+ let nodes = if depth.is_some() {
+ Math::typed(&linear)
+ } else {
+ Math::from_linear(&linear)
+ };
+ let built = Math::paragraph(&nodes, &math.spans()[0].format);
+ if built == math {
+ return Ok(false);
+ }
+ let units: Vec = built.text().chars().collect();
+ let mut caret = units.len() - depth.unwrap_or(0).min(units.len() as u32) as usize;
+ if depth.is_some()
+ && caret >= 2
+ && units[caret - 1] == OBJECT_END
+ && matches!(units[caret - 2], ARGUMENT_SEPARATOR | OBJECT_START)
+ {
+ caret -= 1;
+ }
+ let caret = zone.start
+ + units[..caret]
+ .iter()
+ .map(|c| c.len_utf16() as u32)
+ .sum::();
+ self.replace_zone(engine, paragraph, zone, built, caret)
+ }
+
+ /// Enter inside an equation, as OneNote 2010's equation editor takes it
+ /// (`corpus/math-edit/native-enter`): in the equation's own row a line break the
+ /// paragraph keeps, in an object's argument a new row of it, the argument becoming an
+ /// equation array where it is not one. False where the caret is not inside an equation.
+ pub(super) fn break_equation(&mut self, engine: &mut TextEngine) -> Result {
+ let caret = self.active_outline().selection.positions[1];
+ let Some((paragraph, zone)) = self.caret_zone() else {
+ return Ok(false);
+ };
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let units: Vec = text.slice(zone.clone())?.text().chars().collect();
+ let at = text.slice(zone.start..caret.offset)?.text().chars().count();
+ if at == 0 || at == units.len() || !units.contains(&OBJECT_START) {
+ return Ok(false);
+ }
+ // The argument around the caret: from the control before it at its depth to the one
+ // after.
+ let mut depth = 0;
+ let start = units[..at].iter().rposition(|&c| {
+ match c {
+ OBJECT_END => depth += 1,
+ OBJECT_START if depth > 0 => depth -= 1,
+ OBJECT_START | ARGUMENT_SEPARATOR if depth == 0 => return true,
+ _ => {}
+ }
+ false
+ });
+ let Some(start) = start else {
+ // The equation's own row breaks its line, outside the math.
+ let mut updated = text.clone();
+ let break_format = text_after(text);
+ updated.apply(onestore::page::text::Edit {
+ range: caret.offset..caret.offset,
+ replacement: Paragraph::new("\r".into(), break_format),
+ })?;
+ let after = TextPosition {
+ paragraph,
+ offset: caret.offset + 1,
+ };
+ self.rewrite(engine, paragraph, updated, [after; 2].into())?;
+ return Ok(true);
+ };
+ let mut depth = 0;
+ let end = at
+ + units[at..]
+ .iter()
+ .position(|&c| {
+ match c {
+ OBJECT_START => depth += 1,
+ OBJECT_END if depth > 0 => depth -= 1,
+ OBJECT_END | ARGUMENT_SEPARATOR if depth == 0 => return true,
+ _ => {}
+ }
+ false
+ })
+ .ok_or(EditError::InvalidStructure)?;
+ // The object the argument belongs to opens at the start of its first argument.
+ let mut depth = 0;
+ let opening = units[..=start]
+ .iter()
+ .rposition(|&c| {
+ match c {
+ OBJECT_END => depth += 1,
+ OBJECT_START if depth > 0 => depth -= 1,
+ OBJECT_START => return true,
+ _ => {}
+ }
+ false
+ })
+ .ok_or(EditError::InvalidStructure)?;
+ let math = text.slice(zone.clone())?;
+ let offset =
+ |index: usize| -> u32 { units[..index].iter().map(|c| c.len_utf16() as u32).sum() };
+ let kind = math
+ .format_at(offset(opening) + 1)?
+ .math_object
+ .as_ref()
+ .map(|object| object.kind);
+ let format = math.format_at(offset(at))?.clone();
+ let control = |c: char, kind: u32| {
+ let mut format = format.clone();
+ format.math_object = Some(onestore::document::MathObject {
+ kind,
+ arguments: None,
+ columns: Some(1),
+ symbols: vec!['█'],
+ });
+ (c.to_string(), format)
+ };
+ let piece = |range: Range| -> Result {
+ math.slice(offset(range.start)..offset(range.end))
+ };
+ let mut parts = vec![piece(0..start + 1)?];
+ if kind != Some(15) {
+ parts.push(Paragraph::from_runs([control(OBJECT_START, 15)]));
+ }
+ parts.push(piece(start + 1..at)?);
+ parts.push(Paragraph::from_runs([control(ARGUMENT_SEPARATOR, 15)]));
+ parts.push(piece(at..end)?);
+ if kind != Some(15) {
+ parts.push(Paragraph::from_runs([control(OBJECT_END, 15)]));
+ }
+ parts.push(piece(end..units.len())?);
+ let mut broken = parts.remove(0);
+ for part in parts {
+ broken.append(part)?;
+ }
+ let Some(linear) = Math::parse(&broken)
+ .ok()
+ .and_then(|nodes| Math::linear(&nodes))
+ else {
+ return Ok(false);
+ };
+ let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format);
+ // The caret starts the new row: after as many characters other than spaces as came
+ // before it.
+ let before = broken
+ .text()
+ .chars()
+ .take(at + if kind == Some(15) { 1 } else { 2 });
+ let count = before.filter(|c| *c != ' ').count();
+ let mut seen = 0;
+ let mut caret = zone.start;
+ for c in built.text().chars() {
+ if seen == count {
+ break;
+ }
+ seen += usize::from(c != ' ');
+ caret += c.len_utf16() as u32;
+ }
+ self.replace_zone(engine, paragraph, zone, built, caret)
+ }
+
+ fn replace_zone(
+ &mut self,
+ engine: &mut TextEngine,
+ paragraph: usize,
+ zone: Range,
+ math: Paragraph,
+ caret: u32,
+ ) -> Result {
+ let mut text = self
+ .active_outline()
+ .document
+ .paragraph(paragraph)
+ .ok_or(EditError::InvalidRange)?
+ .clone();
+ text.apply(onestore::page::text::Edit {
+ range: zone,
+ replacement: math,
+ })?;
+ let caret = TextPosition {
+ paragraph,
+ offset: caret,
+ };
+ self.rewrite(engine, paragraph, text, [caret; 2].into())?;
+ Ok(true)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use onestore::document::MathObject;
+ use onestore::page::PageObject;
+
+ type Runs = Vec<(String, Option, Option)>;
+
+ fn runs(text: &Paragraph) -> Runs {
+ let mut start = 0;
+ text.spans()
+ .iter()
+ .map(|span| {
+ let run = text.text()[start..span.end].to_owned();
+ start = span.end;
+ (run, span.format.math_object.clone(), span.format.italic)
+ })
+ .collect()
+ }
+
+ fn equations(bytes: &[u8]) -> Vec {
+ let store = onestore::Store::parse(bytes).unwrap();
+ let index = onestore::RevisionIndex::parse(&store).unwrap();
+ let document = onestore::document::Document::parse(&index).unwrap();
+ let (space, _) = document.pages().unwrap()[0];
+ Page::from_space(&document, space)
+ .unwrap()
+ .objects
+ .iter()
+ .filter_map(|object| match object {
+ PageObject::Outline(outline) => Some(outline),
+ _ => None,
+ })
+ .flat_map(|outline| &outline.paragraphs)
+ .filter_map(|paragraph| paragraph.text())
+ .filter(|text| Math::is_equation(&text.text))
+ .map(|text| {
+ // Text typed after an equation, outside it, is not the editor's.
+ let mut runs = runs(&text.text);
+ runs.retain(|(_, object, _)| object.is_some());
+ runs
+ })
+ .collect()
+ }
+
+ /// Alt+= and typing, each space its own key, store what OneNote 2010's equation editor
+ /// stored for the same keys (`tools/native_math.py`), building up as they are typed.
+ #[test]
+ fn typed_equations_build_up_as_the_equation_editor_stores_them() {
+ let sessions: [(&[u8], &[Option<&str>]); 4] = [
+ (
+ include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
+ &[
+ Some("a_1+b_2"),
+ Some("x_i^2"),
+ Some("(a+b)"),
+ Some("\\int_0^1 x dx"),
+ Some("\\sum_(i=1)^n i"),
+ ],
+ ),
+ (
+ include_bytes!("../../../../corpus/math-edit/native-editor-2/notebook/links.one"),
+ &[
+ Some("\\sqrt x+1"),
+ Some("\\cbrt(x)"),
+ Some("\\sqrt(n&x)"),
+ Some("a/b"),
+ Some("\\lim_(x\\to 0) f(x)"),
+ Some("\\prod_(k=1)^n k"),
+ Some("[a+b]"),
+ Some("\\overline(x)"),
+ Some("x\\hat"),
+ ],
+ ),
+ (
+ include_bytes!("../../../../corpus/math-edit/native-editor-3/notebook/links.one"),
+ &[
+ Some("\\matrix(1&2@3&4)"),
+ Some("\\eqarray(x&=1@y&=2)"),
+ Some("x\\above 2"),
+ Some("x\\below 2"),
+ Some("\\box(x)"),
+ Some("\\rect(x)"),
+ None,
+ Some("\\iint x dx dy"),
+ Some("f(x)/(x^2+1)"),
+ Some("\\sum^n x"),
+ ],
+ ),
+ (
+ include_bytes!("../../../../corpus/math-edit/native-editor-4/notebook/links.one"),
+ &[
+ Some("a_1+b_2"),
+ // The editor was still starting this one and kept its placeholder.
+ None,
+ Some("\\sum_(i=1)^n i"),
+ Some("\\int_0^1 x dx"),
+ Some("\\sqrt x+1"),
+ Some("\\cbrt(x)"),
+ Some("\\sqrt(n&x)"),
+ Some("a/b"),
+ Some("\\prod_(k=1)^n k"),
+ Some("[a+b]"),
+ Some("\\overline(x)"),
+ Some("x\\hat"),
+ Some("\\matrix(1&2@3&4)"),
+ Some("\\eqarray(x&=1@y&=2)"),
+ Some("x\\above 2"),
+ Some("x\\below 2"),
+ Some("\\box(x)"),
+ Some("\\rect(x)"),
+ Some("f(x)/(x^2+1)"),
+ Some("\\sum^n x"),
+ Some("e^(x+1)"),
+ Some("\\alpha+\\beta"),
+ Some("(a+b)/(c+d)"),
+ Some("x^2+y^2=z^2"),
+ // A function applied to a limit, an object this reading does not build.
+ None,
+ Some("{a+b}"),
+ ],
+ ),
+ ];
+ let mut engine = TextEngine::default();
+ for (bytes, typed) in sessions {
+ let native = equations(bytes);
+ assert_eq!(native.len(), typed.len());
+ for (native, typed) in native.iter().zip(typed) {
+ let Some(typed) = typed else {
+ continue;
+ };
+ let mut editor = CanvasEditor::new(
+ &mut engine,
+ TextDocument::new(vec![Paragraph::new(String::new(), Format::default())])
+ .unwrap(),
+ 400.0,
+ )
+ .unwrap();
+ editor.insert_equation(&mut engine).unwrap();
+ for (index, word) in format!("{typed} ").split(' ').enumerate() {
+ if index > 0 {
+ editor.insert(&mut engine, " ").unwrap();
+ }
+ if !word.is_empty() {
+ editor.insert(&mut engine, word).unwrap();
+ }
+ }
+ let text = editor.active_outline().document.paragraph(0).unwrap();
+ assert_eq!(&runs(text), native, "{typed}");
+ }
+ }
+ }
+}
diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs
index 50651b2940fba25352e8e42285d44e5b1a2981ff..1b66ae6dbbb26dd82603d1fbf17ff612e5a7a8b4 100644
--- a/crates/canvas/src/editor/evidence.rs
+++ b/crates/canvas/src/editor/evidence.rs
@@ -1,9 +1,9 @@
//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it
-//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and
-//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts
+//! reported after each keystroke: each outline is built from, and compared with, the
+//! one-line-per-paragraph summaries in `corpus/structural-probe/summaries/`, whose `L` counts
//! paragraph nesting.
-use super::format::{NoteTag, list_definition};
+use super::format::{ListStyle, NoteTag, list_definition};
use super::*;
use onestore::document::Tag;
use onestore::page::ParagraphContent;
@@ -11,7 +11,7 @@ use onestore::page::text::new_id;
fn summary(name: &str) -> String {
std::fs::read_to_string(format!(
- "{}/../../evidence/structural-edits/summaries/{name}.txt",
+ "{}/../../corpus/structural-probe/summaries/{name}.txt",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
@@ -76,7 +76,14 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
.find(|parent| parent.level + 1 == level)
.map(|parent| parent.id);
if !list.is_empty() {
- let definition = list_definition(list.starts_with("num"), &format);
+ let definition = list_definition(
+ if list.starts_with("num") {
+ ListStyle::NUMBER
+ } else {
+ ListStyle::BULLET
+ },
+ &format,
+ );
node.lists = vec![define(format!("list {}", nodes.len()), definition)];
}
for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
@@ -162,9 +169,10 @@ fn render(editor: &CanvasEditor) -> String {
.paragraphs
.iter()
.find(|paragraph| paragraph.id == node.id)
- .and_then(|paragraph| paragraph.number)
+ .and_then(|paragraph| paragraph.number.as_ref())
.unwrap()
- .0;
+ .0
+ .number;
let numeral = crate::outline::numeral(rest.next(), number).unwrap();
format!("num:{numeral}{}", rest.as_str())
}
@@ -274,7 +282,7 @@ fn replay(
layout
.paragraphs
.iter()
- .map(|paragraph| paragraph.number)
+ .map(|paragraph| paragraph.number.clone())
.collect::>()
};
assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
@@ -715,7 +723,7 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {
use onestore::{RevisionIndex, Store, document::Document};
let section = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
- "/../../evidence/structural-edits/probe-section/probe.one"
+ "/../../corpus/structural-probe/probe.one"
))
.unwrap();
let page = |bytes: &[u8], title: &str| {
diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs
index 74d983cbfd1920fd40d9ad7cf49cd4de6a489fc9..e1216345dd3b4d093d096142f9a179cb755a19af 100644
--- a/crates/canvas/src/editor/format.rs
+++ b/crates/canvas/src/editor/format.rs
@@ -23,12 +23,17 @@ pub enum Formatting {
Highlight(Option),
/// Returns text to its paragraph style.
Clear,
+ /// Toggles OneNote's default bullet or number, as Ctrl+. and Ctrl+/ do.
Bullets,
Numbering,
+ /// Applies a style from OneNote's bullet or numbering library; `None` removes lists.
+ List(Option),
Indent,
Outdent,
Align(Alignment),
Tag(NoteTag),
+ /// Removes every note tag from the selected paragraphs.
+ RemoveTags,
/// Checks the selected paragraphs' check boxes, or clears them when all are checked.
Check,
}
@@ -52,8 +57,108 @@ pub enum Alignment {
Right,
}
+/// A style of OneNote 2010's bullet or numbering library, by its place in the gallery.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ListStyle {
+ Bullet(usize),
+ Number(usize),
+}
+
+/// OneNote 2010's bullet library in gallery order, as it stores each: font, glyph and
+/// ListMSAAIndex. Ctrl+. applies the third.
+pub const BULLET_LIBRARY: [(&str, &str, u16); 34] = [
+ ("Wingdings", "l", 3),
+ ("Symbol", "\u{b7}", 1),
+ ("Calibri", "\u{2022}", 1),
+ ("Courier New", "\u{25cb}", 4),
+ ("Wingdings 2", "\u{9d}", 6),
+ ("Wingdings 2", "\u{9c}", 5),
+ ("Wingdings", "\u{b2}", 13),
+ ("Tahoma", "\u{25ca}", 14),
+ ("Wingdings", "w", 15),
+ ("Wingdings 2", "\u{ae}", 34),
+ ("Wingdings", "v", 16),
+ ("Arial", "\u{25aa}", 7),
+ ("Verdana", "\u{25ab}", 8),
+ ("Wingdings", "\u{a7}", 9),
+ ("Arial", "\u{25a1}", 10),
+ ("Wingdings 3", "}", 11),
+ ("Arial", "\u{25ba}", 12),
+ ("Arial", "\u{2192}", 21),
+ ("Symbol", "\u{de}", 22),
+ ("Arial", ">", 20),
+ ("Wingdings", "\u{d8}", 23),
+ ("Arial", "*", 24),
+ ("Wingdings", "\u{ad}", 17),
+ ("Wingdings", "\u{ae}", 19),
+ ("Wingdings", "\u{af}", 18),
+ ("Arial", "-", 25),
+ ("Arial", "\u{2013}", 26),
+ ("Arial", "\u{2014}", 27),
+ ("Wingdings", "J", 28),
+ ("Wingdings", "K", 29),
+ ("Wingdings", "L", 30),
+ ("Wingdings", "\u{fc}", 31),
+ ("Wingdings", "(", 32),
+ ("Wingdings", "*", 33),
+];
+
+/// OneNote 2010's numbering library in gallery order, as NumberListFormat stores each: the
+/// number's sequence follows U+FFFD. Ctrl+/ applies the first.
+pub const NUMBER_LIBRARY: [&str; 19] = [
+ "\u{fffd}\u{0}.",
+ "\u{fffd}\u{5}.",
+ "\u{fffd}\u{4}.",
+ "\u{fffd}\u{3}.",
+ "\u{fffd}\u{2}.",
+ "\u{fffd}\u{1}.",
+ "\u{fffd}\u{0})",
+ "\u{fffd}\u{4})",
+ "\u{fffd}\u{3})",
+ "\u{fffd}\u{2})",
+ "\u{fffd}\u{1})",
+ "(\u{fffd}\u{0})",
+ "(\u{fffd}\u{3})",
+ "(\u{fffd}\u{4})",
+ "(\u{fffd}\u{2})",
+ "(\u{fffd}\u{1})",
+ "\u{fffd}\u{6}.",
+ "\u{fffd}\u{7}.",
+ "\u{fffd}\u{0}-",
+];
+
+impl ListStyle {
+ pub(super) const BULLET: Self = Self::Bullet(2);
+ pub(super) const NUMBER: Self = Self::Number(0);
+
+ /// The library style a stored list has, if it is one.
+ fn of(kind: &Kind) -> Option {
+ let Kind::List {
+ font,
+ format: Some(format),
+ bullet,
+ ..
+ } = kind
+ else {
+ return None;
+ };
+ match bullet {
+ Some(bullet) => BULLET_LIBRARY
+ .iter()
+ .position(|entry| {
+ (Some(entry.0), entry.1, entry.2) == (font.as_deref(), format.as_str(), *bullet)
+ })
+ .map(Self::Bullet),
+ None => NUMBER_LIBRARY
+ .iter()
+ .position(|known| known == format)
+ .map(Self::Number),
+ }
+ }
+}
+
/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions
-/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types.
+/// (`corpus/structural-probe/tags.one`); declared in that order, their action types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoteTag {
ToDo,
@@ -68,7 +173,7 @@ pub enum NoteTag {
}
/// What the selection shows on the toolbar.
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug, Default, PartialEq)]
pub struct FormatState {
/// Attributes every selected character has.
pub toggles: Vec,
@@ -80,6 +185,8 @@ pub struct FormatState {
/// Whether every selected paragraph is bulleted or numbered.
pub bullets: bool,
pub numbering: bool,
+ /// The library style every selected paragraph's list shares.
+ pub list: Option,
/// Tags every selected paragraph has.
pub tags: Vec,
}
@@ -118,7 +225,7 @@ impl Toggle {
}
impl NoteTag {
- pub(super) const ALL: [Self; 9] = [
+ pub(crate) const ALL: [Self; 9] = [
Self::ToDo,
Self::Important,
Self::Question,
@@ -130,21 +237,36 @@ impl NoteTag {
Self::PhoneNumber,
];
+ /// The tag's name, as OneNote stores and lists it.
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::ToDo => "To Do",
+ Self::Important => "Important",
+ Self::Question => "Question",
+ Self::RememberForLater => "Remember for later",
+ Self::Definition => "Definition",
+ Self::Highlight => "Highlight",
+ Self::Contact => "Contact",
+ Self::Address => "Address",
+ Self::PhoneNumber => "Phone number",
+ }
+ }
+
pub(super) fn definition(self) -> Definition {
- let (label, shape, highlight) = match self {
- Self::ToDo => ("To Do", CHECKBOX, None),
- Self::Important => ("Important", 13, None),
- Self::Question => ("Question", 15, None),
- Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)),
- Self::Definition => ("Definition", 0, Some(0x0000_ff00)),
- Self::Highlight => ("Highlight", 136, None),
- Self::Contact => ("Contact", 118, None),
- Self::Address => ("Address", 23, None),
- Self::PhoneNumber => ("Phone number", 18, None),
+ let (shape, highlight) = match self {
+ Self::ToDo => (CHECKBOX, None),
+ Self::Important => (13, None),
+ Self::Question => (15, None),
+ Self::RememberForLater => (0, Some(0x0000_ffff)),
+ Self::Definition => (0, Some(0x0000_ff00)),
+ Self::Highlight => (136, None),
+ Self::Contact => (118, None),
+ Self::Address => (23, None),
+ Self::PhoneNumber => (18, None),
};
Definition {
kind: Kind::TagDefinition {
- label: Some(label.into()),
+ label: Some(self.label().into()),
action_type: Some(self as u16),
shape: Some(shape),
// The highlighting tags also set black text.
@@ -161,7 +283,7 @@ type Ends = [(ExGuid, usize); 2];
/// The selection's ends and the container range holding both; a selection ending at a
/// paragraph's start ends with the paragraph before.
-fn selected(
+pub(super) fn selected(
document: &TextDocument,
selection: Selection,
) -> Result<(Option, Range, Ends), EditError> {
@@ -224,7 +346,7 @@ fn covered(
})
}
-fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
+pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
for node in nodes {
match &mut node.content {
ParagraphContent::Text(_) => change(node),
@@ -239,7 +361,11 @@ fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PagePara
}
/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.
-fn restyle(text: &Paragraph, range: Range, change: impl Fn(&mut Format)) -> Paragraph {
+pub(super) fn restyle(
+ text: &Paragraph,
+ range: Range,
+ change: impl Fn(&mut Format),
+) -> Paragraph {
if text.text().is_empty() {
let mut format = text.spans()[0].format.clone();
change(&mut format);
@@ -316,7 +442,8 @@ fn tags(node: &PageParagraph) -> impl Iterator
- {
impl CanvasEditor {
/// The format text typed at `position` takes: the caret's pending format, else the text's
- /// before it.
+ /// before it. Text after a link's label or field code is plain, as OneNote types it, and
+ /// math typed into an equation is a run between its objects until it is built up.
pub(super) fn typing_format(&self, position: TextPosition) -> Result {
if let Some((id, at, format)) = &self.pending
&& *id == self.active_outline().id
@@ -324,13 +451,23 @@ impl CanvasEditor {
{
return Ok(format.clone());
}
- Ok(self
+ let text = self
.active_outline()
.document
.paragraph(position.paragraph)
- .ok_or(EditError::InvalidRange)?
- .format_at(position.offset)?
- .clone())
+ .ok_or(EditError::InvalidRange)?;
+ let mut format = text.format_at(position.offset)?.clone();
+ if format.hidden == Some(true) && format.hyperlink == Some(true)
+ || super::link::links(text, position.paragraph)
+ .iter()
+ .any(|link| link.code.is_some() && link.label.end == position.offset)
+ {
+ super::link::unlinked(&mut format);
+ }
+ if format.math == Some(true) {
+ format = onestore::page::Math::format(&format);
+ }
+ Ok(format)
}
fn list(&self, node: &PageParagraph) -> Option {
@@ -349,6 +486,10 @@ impl CanvasEditor {
}
}
+ fn list_style(&self, node: &PageParagraph) -> Option {
+ ListStyle::of(&self.definitions.get(node.lists.last()?)?.kind)
+ }
+
fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> {
Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
}
@@ -395,6 +536,7 @@ impl CanvasEditor {
})),
bullets: list(Formatting::Bullets),
numbering: list(Formatting::Numbering),
+ list: common(paragraphs.iter().map(|node| self.list_style(node))),
tags: NoteTag::ALL
.into_iter()
.filter(|tag| {
@@ -429,7 +571,9 @@ impl CanvasEditor {
Formatting::Align(_)
| Formatting::Bullets
| Formatting::Numbering
+ | Formatting::List(_)
| Formatting::Tag(_)
+ | Formatting::RemoveTags
| Formatting::Check
if title =>
{
@@ -455,13 +599,27 @@ impl CanvasEditor {
}
});
}
- Formatting::Bullets | Formatting::Numbering => {
- let remove = covered(&replacement, ends)
- .all(|(node, _)| self.list(node).as_ref() == Some(&command));
+ Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => {
+ let style = match command {
+ Formatting::Bullets => Some(ListStyle::BULLET),
+ Formatting::Numbering => Some(ListStyle::NUMBER),
+ Formatting::List(style) => style,
+ _ => unreachable!(),
+ };
+ // A toggle leaves paragraphs already of its kind as they are, however styled.
+ let has = |node: &PageParagraph| match command {
+ Formatting::List(_) => self.list_style(node) == style,
+ _ => self.list(node).as_ref() == Some(&command),
+ };
+ let remove = style.is_none()
+ || !matches!(command, Formatting::List(_))
+ && covered(&replacement, ends).all(|(node, _)| has(node));
// A list applied after a plain sibling nests under it, as Tab would
- // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there.
+ // (`evidence/structural-edits/xml/pb-1.xml`); removing it, or restyling a
+ // list, leaves it where it is, as OneNote 2010 does.
let nodes = outline.document.container(container)?;
if !remove
+ && nodes[range.start].lists.is_empty()
&& crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())
.is_some_and(|sibling| nodes[sibling].lists.is_empty())
&& let Some(edit) =
@@ -472,13 +630,10 @@ impl CanvasEditor {
}
let wanted = covered(&replacement, ends)
.map(|(node, _)| node)
- .filter(|node| !remove && self.list(node).as_ref() != Some(&command))
- .map(|node| {
+ .filter(|node| !remove && !has(node))
+ .filter_map(|node| {
let format = &node.text().unwrap().text.spans()[0].format;
- (
- node.id,
- list_definition(command == Formatting::Numbering, format),
- )
+ Some((node.id, list_definition(style?, format)))
})
.collect::>();
// A list definition belongs to one paragraph, as OneNote stores it.
@@ -560,6 +715,12 @@ impl CanvasEditor {
}
});
}
+ Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {
+ if ranges.remove(&node.id).is_some() {
+ node.tags.clear();
+ node.text_mut().unwrap().tags.clear();
+ }
+ }),
Formatting::Check => self.check(&mut replacement, ranges, ends),
Formatting::Toggle(_)
| Formatting::Font(_)
@@ -567,10 +728,20 @@ impl CanvasEditor {
| Formatting::Color(_)
| Formatting::Highlight(_)
| Formatting::Clear => {
+ let caret = (ends[0] == ends[1])
+ .then(|| {
+ let [anchor, focus] = selection.positions;
+ let caret = anchor.min(focus);
+ Ok::<_, EditError>((caret, self.typing_format(caret)?))
+ })
+ .transpose()?;
let on = match command {
- Formatting::Toggle(toggle) => !character_formats(covered(&replacement, ends))
- .iter()
- .all(|format| toggle.get(format) == Some(true)),
+ Formatting::Toggle(toggle) => !match &caret {
+ Some((_, format)) => vec![format],
+ None => character_formats(covered(&replacement, ends)),
+ }
+ .iter()
+ .all(|format| toggle.get(format) == Some(true)),
_ => true,
};
let change = |base: &Format, format: &mut Format| match &command {
@@ -606,10 +777,7 @@ impl CanvasEditor {
}
};
let bases = bases(&replacement)?;
- if ends[0] == ends[1] {
- let [anchor, focus] = selection.positions;
- let caret = anchor.min(focus);
- let mut format = self.typing_format(caret)?;
+ if let Some((caret, mut format)) = caret {
change(&bases[&ends[0].0], &mut format);
self.pending = Some((id, caret, format));
return Ok(());
@@ -717,79 +885,47 @@ impl CanvasEditor {
}
}
-/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote
-/// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`,
-/// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at
-/// the first style.
+/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph whose list is one OneNote 2010
+/// steps through as Tab nests it: from Ctrl+. through nine bullets, and from Ctrl+/ through
+/// eight number formats, each as its gallery stores it, starting over after the last
+/// (`evidence/toolbar-17/tab-lists.txt`). Outdenting stops at the first.
pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option {
- const BULLETS: [(&str, &str, u16); 3] = [
- ("Calibri", "\u{2022}", 1),
- ("Courier New", "\u{25cb}", 4),
- ("Wingdings", "\u{a7}", 7),
- ];
- const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}'];
- let Kind::List {
- font,
- format: Some(format),
- restart,
- bullet,
- } = &definition.kind
- else {
+ const BULLETS: [usize; 9] = [2, 3, 13, 14, 9, 7, 15, 26, 8];
+ const NUMBERS: [usize; 8] = [0, 2, 4, 6, 7, 9, 16, 17];
+ let Kind::List { restart, .. } = &definition.kind else {
return None;
};
- let step = |index: usize| {
- if deeper {
- Some((index + 1) % 3)
- } else {
- index.checked_sub(1)
- }
+ let current = ListStyle::of(&definition.kind)?;
+ let (chain, style): (&[usize], fn(usize) -> ListStyle) = match current {
+ ListStyle::Bullet(_) => (&BULLETS, ListStyle::Bullet),
+ ListStyle::Number(_) => (&NUMBERS, ListStyle::Number),
};
- let kind = match bullet {
- Some(_) => {
- let index = BULLETS.iter().position(|(name, glyph, index)| {
- (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index))
- })?;
- let (name, glyph, index) = BULLETS[step(index)?];
- Kind::List {
- font: Some(name.into()),
- format: Some(glyph.into()),
- restart: *restart,
- bullet: Some(index),
- }
- }
- None => {
- let (prefix, rest) = format.split_once('\u{fffd}')?;
- let mut rest = rest.chars();
- let sequence = rest.next()?;
- let index = SEQUENCES.iter().position(|known| *known == sequence)?;
- Kind::List {
- font: font.clone(),
- format: Some(format!(
- "{prefix}\u{fffd}{}{}",
- SEQUENCES[step(index)?],
- rest.as_str()
- )),
- restart: *restart,
- bullet: None,
- }
- }
+ let at = chain.iter().position(|place| style(*place) == current)?;
+ let at = if deeper {
+ (at + 1) % chain.len()
+ } else {
+ at.checked_sub(1)?
};
+ let mut kind = list_definition(style(chain[at]), &Format::default()).kind;
+ if let Kind::List { restart: value, .. } = &mut kind {
+ *value = *restart;
+ }
Some(Definition {
kind,
format: definition.format.clone(),
})
}
-/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet
-/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores
-/// from its COM interface (`corpus/outline-edit/tree`).
-pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
+/// The list OneNote 2010 gives a paragraph with `format` in library `style`, as its gallery
+/// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number
+/// also takes the text's font and language (`corpus/outline-edit/tree`).
+pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition {
let font_size = Some(format.font_size.unwrap_or(11.0));
- if numbering {
- Definition {
+ match style {
+ ListStyle::Number(index) => Definition {
kind: Kind::List {
font: None,
- format: Some("\u{fffd}\u{0}.".into()),
+ format: Some(NUMBER_LIBRARY[index].into()),
restart: None,
bullet: None,
},
@@ -802,20 +938,22 @@ pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
language: format.language,
..Format::default()
},
- }
- } else {
- Definition {
- kind: Kind::List {
- font: Some("Calibri".into()),
- format: Some("\u{2022}".into()),
- restart: None,
- bullet: Some(1),
- },
- format: Format {
- font_size,
- color: Some(AUTOMATIC),
- ..Format::default()
- },
+ },
+ ListStyle::Bullet(index) => {
+ let (font, glyph, bullet) = BULLET_LIBRARY[index];
+ Definition {
+ kind: Kind::List {
+ font: Some(font.into()),
+ format: Some(glyph.into()),
+ restart: None,
+ bullet: Some(bullet),
+ },
+ format: Format {
+ font_size,
+ color: Some(AUTOMATIC),
+ ..Format::default()
+ },
+ }
}
}
}
@@ -930,6 +1068,44 @@ mod tests {
assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]);
}
+ #[test]
+ fn a_second_toggle_at_a_caret_turns_the_typing_format_back_off() {
+ for toggle in Toggle::ALL {
+ for text in ["", "ab"] {
+ let mut engine = TextEngine::default();
+ let mut editor = plain(&mut engine, &[text]);
+ let caret = at(0, text.len() as u32);
+ editor.select([caret; 2].into()).unwrap();
+ let press = |editor: &mut CanvasEditor, engine: &mut TextEngine| {
+ editor.format(engine, Formatting::Toggle(toggle)).unwrap();
+ editor.format_state().unwrap().toggles
+ };
+ assert_eq!(press(&mut editor, &mut engine), [toggle], "{toggle:?}");
+ assert_eq!(press(&mut editor, &mut engine), [], "{toggle:?}");
+ editor.insert(&mut engine, "x").unwrap();
+ assert_eq!(
+ runs(&editor, |format| toggle.get(format))[0],
+ [run(&format!("{text}x"), None)]
+ );
+ }
+ let mut engine = TextEngine::default();
+ let mut on = calibri();
+ *toggle.slot(&mut on) = Some(true);
+ let mut editor = editor(&mut engine, vec![Paragraph::new("ab".into(), on)]);
+ editor.select([at(0, 1); 2].into()).unwrap();
+ editor
+ .format(&mut engine, Formatting::Toggle(toggle))
+ .unwrap();
+ assert_eq!(editor.format_state().unwrap().toggles, [], "{toggle:?}");
+ editor.insert(&mut engine, "x").unwrap();
+ assert_eq!(
+ runs(&editor, |format| toggle.get(format) == Some(true))[0],
+ [("a".into(), true), ("x".into(), false), ("b".into(), true)],
+ "{toggle:?}"
+ );
+ }
+ }
+
#[test]
fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() {
let mut engine = TextEngine::default();
@@ -1112,7 +1288,7 @@ mod tests {
let markers = paragraph.markers.iter().map(|(marker, origin)| {
(*origin, marker.lines().next().unwrap().0.metrics().advance)
});
- (paragraph.number, markers.collect::>())
+ (paragraph.number.clone(), markers.collect::>())
})
.collect::>()
};
@@ -1121,7 +1297,7 @@ mod tests {
.shaped
.paragraphs
.iter()
- .map(|paragraph| paragraph.number.map(|(number, _)| number))
+ .map(|paragraph| paragraph.number.as_ref().map(|(count, _)| count.number))
.collect()
}
@@ -1139,7 +1315,7 @@ mod tests {
let bullet = editor.active_outline().document.nodes()[0].lists[0];
assert_eq!(
editor.definitions[&bullet],
- list_definition(false, &calibri())
+ list_definition(ListStyle::BULLET, &calibri())
);
editor.format(&mut engine, Formatting::Numbering).unwrap();
let state = editor.format_state().unwrap();
@@ -1197,6 +1373,136 @@ mod tests {
assert_eq!(numbers(&mut engine, &editor), [None; 4]);
}
+ #[test]
+ fn library_styles_replace_any_list_and_none_removes_them() {
+ let mut engine = TextEngine::default();
+ let mut editor = plain(&mut engine, &["one", "two", "three"]);
+ let original = editor.active_outline().document.clone();
+ let all = Selection::from([at(0, 0), at(2, 5)]);
+ editor.select(all).unwrap();
+ editor.format(&mut engine, Formatting::Bullets).unwrap();
+ assert_eq!(editor.format_state().unwrap().list, Some(ListStyle::BULLET));
+ let pick = |style| Formatting::List(Some(style));
+ editor
+ .format(&mut engine, pick(ListStyle::Bullet(0)))
+ .unwrap();
+ let state = editor.format_state().unwrap();
+ assert!(state.bullets && !state.numbering);
+ assert_eq!(state.list, Some(ListStyle::Bullet(0)));
+ let first = editor.active_outline().document.nodes()[0].lists[0];
+ assert_eq!(
+ editor.definitions[&first],
+ list_definition(ListStyle::Bullet(0), &calibri())
+ );
+ // Picking the style again leaves the lists as they are.
+ let stored = editor.active_outline().document.clone();
+ editor
+ .format(&mut engine, pick(ListStyle::Bullet(0)))
+ .unwrap();
+ assert_eq!(editor.active_outline().document, stored);
+ // A number format replaces the bullets; "First." lays out as any number does.
+ editor
+ .format(&mut engine, pick(ListStyle::Number(17)))
+ .unwrap();
+ let state = editor.format_state().unwrap();
+ assert!(state.numbering && !state.bullets);
+ assert_eq!(state.list, Some(ListStyle::Number(17)));
+ assert_eq!(numbers(&mut engine, &editor), [Some(1), Some(2), Some(3)]);
+ // Paragraphs of different styles share none.
+ editor.select([at(1, 0); 2].into()).unwrap();
+ editor
+ .format(&mut engine, pick(ListStyle::Number(0)))
+ .unwrap();
+ editor.select(all).unwrap();
+ let state = editor.format_state().unwrap();
+ assert!(state.numbering && state.list.is_none());
+ editor.format(&mut engine, Formatting::List(None)).unwrap();
+ let state = editor.format_state().unwrap();
+ assert!(!state.bullets && !state.numbering && state.list.is_none());
+ assert_eq!(numbers(&mut engine, &editor), [None; 3]);
+ for _ in 0..6 {
+ editor.undo(&mut engine).unwrap();
+ }
+ assert_eq!(editor.active_outline().document, original);
+ }
+
+ #[test]
+ fn restyling_a_list_after_a_plain_sibling_leaves_it_where_it_is() {
+ let mut engine = TextEngine::default();
+ let mut editor = plain(&mut engine, &["head", "item"]);
+ let levels = |editor: &CanvasEditor| {
+ editor
+ .active_outline()
+ .document
+ .nodes()
+ .iter()
+ .map(|node| node.level)
+ .collect::>()
+ };
+ editor.select([at(1, 0); 2].into()).unwrap();
+ editor.format(&mut engine, Formatting::Numbering).unwrap();
+ assert_eq!(levels(&editor), [1, 2]);
+ editor.format(&mut engine, Formatting::Outdent).unwrap();
+ assert_eq!(levels(&editor), [1, 1]);
+ editor.format(&mut engine, Formatting::Bullets).unwrap();
+ editor
+ .format(&mut engine, Formatting::List(Some(ListStyle::Bullet(0))))
+ .unwrap();
+ assert_eq!(levels(&editor), [1, 1]);
+ assert_eq!(
+ editor.format_state().unwrap().list,
+ Some(ListStyle::Bullet(0))
+ );
+ }
+
+ #[test]
+ fn tab_steps_through_onenotes_nested_bullets_and_numbers() {
+ let chain = |start: ListStyle, steps: usize| {
+ let mut definition = list_definition(start, &calibri());
+ let mut styles = vec![ListStyle::of(&definition.kind).unwrap()];
+ for _ in 0..steps {
+ definition = nested_list(&definition, true).unwrap();
+ styles.push(ListStyle::of(&definition.kind).unwrap());
+ }
+ (styles, definition)
+ };
+ let (bullets, deepest) = chain(ListStyle::BULLET, 9);
+ assert_eq!(
+ bullets,
+ [2, 3, 13, 14, 9, 7, 15, 26, 8, 2].map(ListStyle::Bullet)
+ );
+ // Wingdings § is ListMSAAIndex 9 here, as the gallery stores it.
+ assert_eq!(BULLET_LIBRARY[13], ("Wingdings", "\u{a7}", 9));
+ assert_eq!(
+ deepest.format,
+ list_definition(ListStyle::BULLET, &calibri()).format
+ );
+ let (numbers, _) = chain(ListStyle::NUMBER, 8);
+ assert_eq!(
+ numbers,
+ [0, 2, 4, 6, 7, 9, 16, 17, 0].map(ListStyle::Number)
+ );
+ let second = nested_list(&list_definition(ListStyle::NUMBER, &calibri()), true).unwrap();
+ assert_eq!(
+ ListStyle::of(&nested_list(&second, false).unwrap().kind),
+ Some(ListStyle::NUMBER)
+ );
+ assert!(nested_list(&list_definition(ListStyle::BULLET, &calibri()), false).is_none());
+ // A style off both paths keeps its list.
+ assert!(nested_list(&list_definition(ListStyle::Bullet(33), &calibri()), true).is_none());
+ }
+
+ #[test]
+ fn every_library_style_reads_back_as_itself() {
+ let styles = (0..BULLET_LIBRARY.len())
+ .map(ListStyle::Bullet)
+ .chain((0..NUMBER_LIBRARY.len()).map(ListStyle::Number));
+ for style in styles {
+ let definition = list_definition(style, &calibri());
+ assert_eq!(ListStyle::of(&definition.kind), Some(style));
+ }
+ }
+
#[test]
fn numbering_follows_stored_sequences_restarts_and_nesting() {
let mut engine = TextEngine::default();
@@ -1207,10 +1513,10 @@ mod tests {
('\u{1}', None, 1),
('\u{4}', None, 2),
('\u{1}', Some(9), 1),
- ('\u{0}', None, 1),
+ ('\u{1}', None, 1),
]) {
let id = new_id().unwrap();
- let mut definition = list_definition(true, &calibri());
+ let mut definition = list_definition(ListStyle::NUMBER, &calibri());
let Kind::List {
format,
restart: value,
@@ -1237,6 +1543,78 @@ mod tests {
);
}
+ /// OneNote 2010's numbers for siblings and children in the formats and levels given, as
+ /// its COM interface reports them (`evidence/toolbar-17/restart.txt`); `None` is a plain
+ /// paragraph, `Some("•")` a bullet.
+ #[test]
+ fn numbers_restart_where_the_format_changes() {
+ let numbered = |paragraphs: &[(Option<&str>, u32)]| {
+ let mut engine = TextEngine::default();
+ let texts = vec!["x"; paragraphs.len()];
+ let mut editor = plain(&mut engine, &texts);
+ let mut nodes = editor.active_outline().document.nodes().to_vec();
+ for (node, (list, level)) in nodes.iter_mut().zip(paragraphs) {
+ node.level = *level;
+ let style = match list {
+ None => continue,
+ Some("•") => ListStyle::BULLET,
+ Some(_) => ListStyle::NUMBER,
+ };
+ let mut definition = list_definition(style, &calibri());
+ if let (Some(value), Kind::List { format, .. }) = (list, &mut definition.kind)
+ && style == ListStyle::NUMBER
+ {
+ *format = Some((*value).into());
+ }
+ let id = new_id().unwrap();
+ editor.definitions.insert(id, definition);
+ node.lists = vec![id];
+ }
+ let outline = Outline {
+ paragraphs: nodes,
+ ..editor.active_outline().snapshot()
+ };
+ let editor =
+ CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions)
+ .unwrap();
+ numbers(&mut engine, &editor)
+ };
+ let [dot, paren, letter] = ["\u{fffd}\u{0}.", "\u{fffd}\u{0})", "\u{fffd}\u{4}."];
+ // Another format starts again, and so does the first format after it.
+ assert_eq!(
+ numbered(&[
+ (Some(dot), 1),
+ (Some(dot), 1),
+ (Some(paren), 1),
+ (Some(dot), 1),
+ (Some(paren), 1),
+ ]),
+ [Some(1), Some(2), Some(1), Some(1), Some(1)]
+ );
+ // Plain and bulleted siblings leave the count alone.
+ assert_eq!(
+ numbered(&[(Some(dot), 1), (None, 1), (Some("•"), 1), (Some(dot), 1)]),
+ [Some(1), None, None, Some(2)]
+ );
+ // So does another sequence; children count apart from their parents, afresh under each.
+ assert_eq!(
+ numbered(&[(Some(dot), 1), (Some(letter), 1), (Some(dot), 1)]),
+ [Some(1), Some(1), Some(1)]
+ );
+ assert_eq!(
+ numbered(&[
+ (Some(dot), 1),
+ (Some(dot), 2),
+ (Some(dot), 2),
+ (Some(dot), 1),
+ (Some(dot), 2),
+ (None, 1),
+ (Some(dot), 1),
+ ]),
+ [Some(1), Some(1), Some(2), Some(2), Some(1), None, Some(3)]
+ );
+ }
+
fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> {
let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap();
tags(node)
@@ -1389,7 +1767,7 @@ mod tests {
#[test]
fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {
use onestore::{RevisionIndex, Store, document::Document};
- let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one");
+ let bytes = include_bytes!("../../../../corpus/structural-probe/tags.one");
let store = Store::parse(bytes).unwrap();
let index = RevisionIndex::parse(&store).unwrap();
let document = Document::parse(&index).unwrap();
@@ -1561,6 +1939,7 @@ mod tests {
alignment: Some(Alignment::Center),
bullets: false,
numbering: true,
+ list: Some(ListStyle::NUMBER),
tags: vec![NoteTag::ToDo, NoteTag::Question],
}
);
diff --git a/crates/canvas/src/editor/link.rs b/crates/canvas/src/editor/link.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3be719d5c34e97782b4beeebdd9f7563debd9c0b
--- /dev/null
+++ b/crates/canvas/src/editor/link.rs
@@ -0,0 +1,681 @@
+//! Hyperlinks as OneNote 2010 edits them (`corpus/link-edit/native-typed`): the Link dialog
+//! stores the address as typed in a hidden `HYPERLINK` field code before the label, a URL
+//! typed and ended by a space or Enter becomes a link of its own text, typing after a label
+//! is plain text, and Remove Link leaves the label as plain text.
+
+use super::format::{leaves_mut, restyle, selected};
+use super::*;
+
+/// A link in a paragraph, in UTF-16 offsets of its stored text.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Link {
+ pub paragraph: usize,
+ /// The hidden field code naming the address, before the label; none where the label is
+ /// its own address.
+ pub code: Option>,
+ pub label: Range,
+ pub target: String,
+}
+
+impl Link {
+ fn range(&self) -> Range {
+ self.code
+ .as_ref()
+ .map_or(self.label.start, |code| code.start)..self.label.end
+ }
+}
+
+/// The links in `text`, the text of paragraph `paragraph`.
+pub(crate) fn links(text: &Paragraph, paragraph: usize) -> Vec {
+ let mut found: Vec = Vec::new();
+ let mut code: Option<(Range, String)> = None;
+ let (mut byte, mut unit) = (0, 0);
+ for span in text.spans() {
+ let fragment = &text.text()[byte..span.end];
+ let range = unit..unit + fragment.encode_utf16().count() as u32;
+ (byte, unit) = (span.end, range.end);
+ let format = &span.format;
+ if format.hyperlink != Some(true) {
+ code = None;
+ continue;
+ }
+ if format.hidden == Some(true) {
+ code = fragment
+ .strip_prefix("\u{fddf}HYPERLINK \"")
+ .and_then(|target| target.strip_suffix('"'))
+ .map(|target| (range, target.to_owned()));
+ continue;
+ }
+ let labelled = format.hyperlink_label == Some(true);
+ match (code.take(), found.last_mut()) {
+ (Some((code, target)), _) if code.end == range.start => found.push(Link {
+ paragraph,
+ code: Some(code),
+ label: range,
+ target,
+ }),
+ // A label runs on across format changes; a bare link is its own text.
+ (_, Some(last)) if last.label.end == range.start && last.code.is_some() == labelled => {
+ last.label.end = range.end;
+ }
+ _ => found.push(Link {
+ paragraph,
+ code: None,
+ label: range,
+ target: String::new(),
+ }),
+ }
+ }
+ for link in &mut found {
+ if link.code.is_none() {
+ let start = text.byte_offset(link.label.start).unwrap_or(0);
+ let end = text.byte_offset(link.label.end).unwrap_or(start);
+ link.target = text.text()[start..end].to_owned();
+ }
+ }
+ found
+}
+
+/// Where a typed URL lies in `word`, a run of text ended by a space or Enter, as OneNote
+/// links it: from a scheme it knows (or `www.`, or a share's `\\`) preceded by punctuation
+/// only, to before any trailing punctuation or unmatched closing bracket.
+pub(crate) fn typed_url(word: &str) -> Option> {
+ const SCHEMES: [&str; 9] = [
+ "http://", "https://", "ftp://", "file://", "mailto:", "news:", "onenote:", "www.", "\\\\",
+ ];
+ let lower = word.to_ascii_lowercase();
+ let (start, scheme) = SCHEMES
+ .iter()
+ .filter_map(|scheme| Some((lower.find(scheme)?, scheme.len())))
+ .min()?;
+ if word[..start].chars().any(char::is_alphanumeric) {
+ return None;
+ }
+ let mut end = word.len();
+ while let Some(last) = word[start..end].chars().last() {
+ let count = |c: char| word[start..end].matches(c).count();
+ let trailing = match last {
+ '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => true,
+ ')' => count('(') < count(')'),
+ ']' => count('[') < count(']'),
+ '}' => count('{') < count('}'),
+ _ => false,
+ };
+ if !trailing {
+ break;
+ }
+ end -= last.len_utf8();
+ }
+ (end > start + scheme).then_some(start..end)
+}
+
+/// The URLs written as text in `text`, as byte ranges: OneNote links them when it opens a
+/// page, so they show and open as links without being stored as ones.
+pub fn shown_urls(text: &Paragraph) -> Vec> {
+ let plain =
+ |format: &Format| ![format.hyperlink, format.hidden, format.math].contains(&Some(true));
+ let mut urls = Vec::new();
+ let mut start = 0;
+ for span in text.spans() {
+ if !plain(&span.format) {
+ start = span.end;
+ continue;
+ }
+ let run = &text.text()[start..span.end];
+ let mut at = 0;
+ for word in run.split(char::is_whitespace) {
+ // Most words hold none of what a URL starts with.
+ let candidate = word.contains([':', '\\'])
+ || word
+ .as_bytes()
+ .windows(4)
+ .any(|w| w.eq_ignore_ascii_case(b"www."));
+ if candidate && let Some(url) = typed_url(word) {
+ urls.push(start + at + url.start..start + at + url.end);
+ }
+ at += word.len()
+ + run[at + word.len()..]
+ .chars()
+ .next()
+ .map_or(0, char::len_utf8);
+ }
+ start = span.end;
+ }
+ urls
+}
+
+/// The format of the character at UTF-16 `offset`.
+pub(crate) fn format_after(text: &Paragraph, offset: u32) -> Option<&Format> {
+ let byte = text.byte_offset(offset).ok()?;
+ let length = text.text()[byte..].chars().next()?.len_utf16() as u32;
+ text.format_at(offset + length).ok()
+}
+
+/// Text that follows a link rather than extending it.
+pub(crate) fn unlinked(format: &mut Format) {
+ format.hidden = format.hidden.map(|_| false);
+ format.hyperlink = format.hyperlink.map(|_| false);
+ format.hyperlink_label = None;
+}
+
+/// A field code naming `address`, which cannot hold its quotes or line breaks.
+fn field_code(address: &str) -> String {
+ let address: String = address
+ .trim()
+ .chars()
+ .filter(|c| !c.is_control() && *c != '\u{fddf}')
+ .map(|c| {
+ if c == '"' {
+ "%22".to_owned()
+ } else {
+ c.to_string()
+ }
+ })
+ .collect();
+ format!("\u{fddf}HYPERLINK \"{address}\"")
+}
+
+impl CanvasEditor {
+ /// The link in the active outline at `position`, its ends included.
+ pub fn link_at(&self, position: TextPosition) -> Option {
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(position.paragraph)?;
+ links(text, position.paragraph).into_iter().find(|link| {
+ link.range().contains(&position.offset) || link.label.end == position.offset
+ })
+ }
+
+ /// The address of the link drawn under document point `point` of outline `id`, which a
+ /// click opens as OneNote's does.
+ pub fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option {
+ let outline = self.visible_outlines().find(|outline| outline.id == id)?;
+ let index = outline.paragraph_at(x, y).ok()?;
+ let paragraph = &outline.shaped.paragraphs[index];
+ let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
+ let cursor = paragraph.text.hit_test(point[0], point[1]);
+ let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
+ let visible = paragraph.projection.text();
+ let byte = cursor.index();
+ // The character under the point lies on the side of the nearest boundary it is on.
+ let at = if point[0] < edge {
+ visible.text()[..byte].char_indices().next_back()?.0
+ } else {
+ visible.text()[byte..].chars().next()?;
+ byte
+ };
+ let (line, _) = paragraph
+ .text
+ .lines()
+ .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
+ if point[0] > line.metrics().advance {
+ return None;
+ }
+ let source = paragraph
+ .projection
+ .source_offset(
+ visible.utf16_offset(at).ok()?,
+ onestore::page::text::Affinity::Downstream,
+ )
+ .ok()?;
+ let paragraph = outline.source_index(index);
+ links(outline.document.paragraph(paragraph)?, paragraph)
+ .into_iter()
+ .find(|link| link.label.contains(&source))
+ .map(|link| link.target)
+ .or_else(|| {
+ shown_urls(visible)
+ .into_iter()
+ .find(|url| url.contains(&at))
+ .map(|url| visible.text()[url].to_owned())
+ })
+ }
+
+ /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
+ /// paragraph, else the link or word at the caret, else nothing at the caret. With the
+ /// text it shows and the address it holds.
+ fn link_range(&self) -> Result<(usize, Range, String, String), EditError> {
+ let [anchor, focus] = self.active_outline().selection.positions;
+ let (start, end) = (anchor.min(focus), anchor.max(focus));
+ if start.paragraph != end.paragraph {
+ return Err(EditError::UnsupportedContent);
+ }
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(start.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let shown = |range: Range| -> Result {
+ Ok(text.slice(range)?.project()?.text().text().to_owned())
+ };
+ if start != end {
+ let mut range = start.offset..end.offset;
+ // Links the selection touches are relinked whole.
+ for link in links(text, start.paragraph) {
+ let whole = link.range();
+ if whole.start < range.end && range.start < whole.end {
+ range = range.start.min(whole.start)..range.end.max(whole.end);
+ }
+ }
+ let address = self
+ .link_at(start)
+ .filter(|link| link.range().start <= range.start && range.end <= link.label.end)
+ .map(|link| link.target)
+ .unwrap_or_default();
+ return Ok((start.paragraph, range.clone(), shown(range)?, address));
+ }
+ if let Some(link) = self.link_at(start) {
+ let label = shown(link.label.clone())?;
+ return Ok((start.paragraph, link.range(), label, link.target));
+ }
+ // The word at the caret, within plain visible text.
+ let byte = text.byte_offset(start.offset)?;
+ let plain = |at: usize, c: char| {
+ !c.is_whitespace()
+ && text
+ .utf16_offset(at)
+ .ok()
+ .and_then(|at| format_after(text, at))
+ .is_some_and(|format| {
+ ![format.hidden, format.hyperlink, format.math].contains(&Some(true))
+ })
+ };
+ let from = text.text()[..byte]
+ .char_indices()
+ .rev()
+ .take_while(|&(at, c)| plain(at, c))
+ .last()
+ .map_or(byte, |(at, _)| at);
+ let to = text.text()[byte..]
+ .char_indices()
+ .take_while(|&(at, c)| plain(byte + at, c))
+ .last()
+ .map_or(byte, |(at, c)| byte + at + c.len_utf8());
+ let range = text.utf16_offset(from)?..text.utf16_offset(to)?;
+ Ok((start.paragraph, range.clone(), shown(range)?, String::new()))
+ }
+
+ /// The text and address the Link dialog opens with.
+ pub fn link_prefill(&self) -> (String, String) {
+ self.link_range()
+ .map(|(_, _, text, address)| (text, address))
+ .unwrap_or_default()
+ }
+
+ /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the
+ /// address where `text` is empty, linked to `address`, and the caret follows it.
+ pub fn set_link(
+ &mut self,
+ engine: &mut TextEngine,
+ text: &str,
+ address: &str,
+ ) -> Result<(), EditorError> {
+ if address.trim().is_empty() {
+ return Ok(());
+ }
+ let (paragraph, range, _, _) = self.link_range()?;
+ let current = self
+ .active_outline()
+ .document
+ .paragraph(paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let label: String = if text.is_empty() {
+ address.trim()
+ } else {
+ text
+ }
+ .chars()
+ .map(|c| if c.is_control() { ' ' } else { c })
+ .collect();
+ let mut base = match format_after(current, range.start).filter(|_| !range.is_empty()) {
+ Some(format) => format.clone(),
+ None => self.typing_format(TextPosition {
+ paragraph,
+ offset: range.start,
+ })?,
+ };
+ unlinked(&mut base);
+ base.hidden = Some(false);
+ let mut code = base.clone();
+ code.hidden = Some(true);
+ code.hyperlink = Some(true);
+ code.hyperlink_label = Some(true);
+ let mut shown = base;
+ shown.hyperlink = Some(true);
+ shown.hyperlink_label = Some(true);
+ let replacement = Paragraph::from_runs([(field_code(address), code), (label, shown)]);
+ let length = replacement.utf16_offset(replacement.text().len())?;
+ let mut updated = current.clone();
+ updated.apply(onestore::page::text::Edit {
+ range: range.clone(),
+ replacement,
+ })?;
+ let caret = TextPosition {
+ paragraph,
+ offset: range.start + length,
+ };
+ self.rewrite(engine, paragraph, updated, [caret; 2].into())
+ }
+
+ /// Remove Link: the link at the caret keeps its label as plain text.
+ pub fn remove_link(&mut self, engine: &mut TextEngine) -> Result {
+ let [anchor, focus] = self.active_outline().selection.positions;
+ let Some(link) = self.link_at(anchor.min(focus)) else {
+ return Ok(false);
+ };
+ let text = self
+ .active_outline()
+ .document
+ .paragraph(link.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let start = text.byte_offset(link.label.start)?;
+ let end = text.byte_offset(link.label.end)?;
+ let mut updated = restyle(text, start..end, unlinked);
+ let mut label = link.label.clone();
+ if let Some(code) = &link.code {
+ updated.apply(onestore::page::text::Edit {
+ range: code.clone(),
+ replacement: Paragraph::new(String::new(), Format::default()),
+ })?;
+ label = label.start - code.len() as u32..label.end - code.len() as u32;
+ }
+ let at = |offset| TextPosition {
+ paragraph: link.paragraph,
+ offset,
+ };
+ self.rewrite(
+ engine,
+ link.paragraph,
+ updated,
+ [at(label.start), at(label.end)].into(),
+ )?;
+ Ok(true)
+ }
+
+ /// Select Link: the label of the link at the caret.
+ pub fn select_link(&mut self) -> Result {
+ let [anchor, focus] = self.active_outline().selection.positions;
+ let Some(link) = self.link_at(anchor.min(focus)) else {
+ return Ok(false);
+ };
+ let at = |offset| TextPosition {
+ paragraph: link.paragraph,
+ offset,
+ };
+ self.select([at(link.label.start), at(link.label.end)].into())?;
+ Ok(true)
+ }
+
+ /// Links the URL typed before `end` once a space or Enter ends it, as its own undo step.
+ pub(super) fn link_typed_url(
+ &mut self,
+ engine: &mut TextEngine,
+ end: TextPosition,
+ ) -> Result<(), EditorError> {
+ let Some(text) = self.active_outline().document.paragraph(end.paragraph) else {
+ return Ok(());
+ };
+ let byte = text.byte_offset(end.offset)?;
+ let from = text.text()[..byte]
+ .char_indices()
+ .rev()
+ .take_while(|&(_, c)| !c.is_whitespace())
+ .last()
+ .map_or(byte, |(at, _)| at);
+ let Some(url) = typed_url(&text.text()[from..byte]) else {
+ return Ok(());
+ };
+ let url = from + url.start..from + url.end;
+ // Only plain text links; a link, field code or equation there stays as it is.
+ let mut offset = text.utf16_offset(url.start)?;
+ for c in text.text()[url.clone()].chars() {
+ let format = format_after(text, offset).ok_or(EditError::InvalidRange)?;
+ if [format.hyperlink, format.hidden, format.math].contains(&Some(true)) {
+ return Ok(());
+ }
+ offset += c.len_utf16() as u32;
+ }
+ let updated = restyle(text, url, |format| format.hyperlink = Some(true));
+ let selection = self.active_outline().selection;
+ self.rewrite(engine, end.paragraph, updated, selection)
+ }
+
+ /// Moves a caret between a field code and its label to before the code, so what is
+ /// typed there stays outside the link.
+ pub(super) fn leave_link_code(&mut self) -> Result<(), EditError> {
+ let selection = self.active_outline().selection;
+ let [anchor, focus] = selection.positions;
+ if anchor != focus {
+ return Ok(());
+ }
+ let Some(link) = self.link_at(focus) else {
+ return Ok(());
+ };
+ if let Some(code) = link.code.filter(|code| code.end == focus.offset) {
+ let at = TextPosition {
+ paragraph: focus.paragraph,
+ offset: code.start,
+ };
+ self.active_outline_mut().selection = [at; 2].into();
+ }
+ Ok(())
+ }
+
+ /// Replaces the text of paragraph `paragraph` of the active outline as one undo step
+ /// ending at `selection`.
+ pub(super) fn rewrite(
+ &mut self,
+ engine: &mut TextEngine,
+ paragraph: usize,
+ text: Paragraph,
+ selection: Selection,
+ ) -> Result<(), EditorError> {
+ let document = &self.active_outline().document;
+ let at = TextPosition {
+ paragraph,
+ offset: 0,
+ };
+ let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?;
+ let mut replacement = document.container(container)?[range.clone()].to_vec();
+ leaves_mut(&mut replacement, &mut |node| {
+ if node.id == id {
+ node.text_mut().unwrap().text = text.clone();
+ }
+ });
+ self.commit(
+ engine,
+ DocumentEdit {
+ columns: BTreeMap::new(),
+ container,
+ range,
+ replacement,
+ },
+ selection,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use onestore::page::PageObject;
+
+ /// A paragraph's text as runs of (text, hyperlink, label, hidden), runs with the same
+ /// flags merged.
+ fn flags(text: &Paragraph) -> Vec<(String, bool, bool, bool)> {
+ let mut runs: Vec<(String, bool, bool, bool)> = Vec::new();
+ let mut start = 0;
+ for span in text.spans() {
+ let format = &span.format;
+ let run = (
+ text.text()[start..span.end].to_owned(),
+ format.hyperlink == Some(true),
+ format.hyperlink_label == Some(true),
+ format.hidden == Some(true),
+ );
+ start = span.end;
+ match runs.last_mut() {
+ Some(last) if (last.1, last.2, last.3) == (run.1, run.2, run.3) => {
+ last.0.push_str(&run.0);
+ }
+ _ => runs.push(run),
+ }
+ }
+ runs
+ }
+
+ fn native_paragraphs() -> Vec {
+ let bytes = include_bytes!("../../../../corpus/link-edit/native-typed/notebook/links.one");
+ let store = onestore::Store::parse(bytes).unwrap();
+ let index = onestore::RevisionIndex::parse(&store).unwrap();
+ let document = onestore::document::Document::parse(&index).unwrap();
+ let (space, _) = document.pages().unwrap()[0];
+ let page = Page::from_space(&document, space).unwrap();
+ page.objects
+ .iter()
+ .filter_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(outline),
+ _ => None,
+ })
+ .flat_map(|outline| &outline.paragraphs)
+ .filter_map(|paragraph| paragraph.text().map(|text| text.text.clone()))
+ .skip(1)
+ .collect()
+ }
+
+ /// Types `text`, each space on its own as a key does.
+ fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
+ for (index, word) in text.split(' ').enumerate() {
+ if index > 0 {
+ editor.insert(engine, " ").unwrap();
+ }
+ if !word.is_empty() {
+ editor.insert(engine, word).unwrap();
+ }
+ }
+ }
+
+ fn at(paragraph: usize, offset: u32) -> Selection {
+ [TextPosition { paragraph, offset }; 2].into()
+ }
+
+ /// The editor replays what `tools/native_links.py` typed into OneNote 2010 and stores
+ /// the same links: typed URLs, the Link dialog on a word, on a selection and on nothing,
+ /// typing after a link, and Remove Link.
+ #[test]
+ fn link_editing_stores_what_onenote_stored() {
+ let native = native_paragraphs();
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::new(
+ &mut engine,
+ TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
+ 400.0,
+ )
+ .unwrap();
+ let engine = &mut engine;
+ for url in [
+ "www.example.com",
+ "http://example.org/a?b=1",
+ "https://a.example/p.",
+ "(http://b.example/q)",
+ "ftp://c.example/f",
+ "mailto:me@example.com",
+ "me@example.com",
+ "example.com",
+ "onenote:#Page§ion-id={x}",
+ "file://c:/windows",
+ "\\\\server\\share\\f",
+ "news:comp.lang",
+ "www.d.example,",
+ "HTTP://E.EXAMPLE/UP",
+ "https://f.example/a_b-c~d%20e#frag",
+ ] {
+ // OneNote capitalized the first letter of each sentence as it was typed.
+ let after = if url.ends_with('.') { "Z" } else { "z" };
+ typed(&mut editor, engine, &format!("T {url} {after}"));
+ editor.enter(engine, false).unwrap();
+ }
+ typed(&mut editor, engine, "See http://g.example/end");
+ editor.enter(engine, false).unwrap();
+ typed(&mut editor, engine, "Zplain words here");
+ let paragraph = editor.selection().positions[0].paragraph;
+ editor.select(at(paragraph, 13)).unwrap();
+ assert_eq!(editor.link_prefill(), ("here".into(), String::new()));
+ editor.set_link(engine, "here", "example.net/x").unwrap();
+ typed(&mut editor, engine, "Q");
+ editor.enter(engine, false).unwrap();
+ typed(&mut editor, engine, "Select me please");
+ let paragraph = editor.selection().positions[0].paragraph;
+ editor
+ .select(
+ [
+ TextPosition {
+ paragraph,
+ offset: 7,
+ },
+ TextPosition {
+ paragraph,
+ offset: 16,
+ },
+ ]
+ .into(),
+ )
+ .unwrap();
+ assert_eq!(editor.link_prefill(), ("me please".into(), String::new()));
+ editor
+ .set_link(engine, "me please", "https://example.com/selected")
+ .unwrap();
+ editor.enter(engine, false).unwrap();
+ assert_eq!(editor.link_prefill(), (String::new(), String::new()));
+ editor
+ .set_link(engine, "", "https://example.com/only")
+ .unwrap();
+ typed(&mut editor, engine, " tail");
+ editor.enter(engine, false).unwrap();
+ typed(&mut editor, engine, "Gone ftp://h.example/f z");
+ let paragraph = editor.selection().positions[0].paragraph;
+ editor.select(at(paragraph, 8)).unwrap();
+ assert!(editor.remove_link(engine).unwrap());
+ let written: Vec<_> = editor
+ .active_outline()
+ .document
+ .text_nodes()
+ .map(|node| flags(&node.text().unwrap().text))
+ .collect();
+ let expected: Vec<_> = native.iter().map(flags).collect();
+ assert_eq!(written, expected);
+ }
+
+ /// Every URL OneNote 2010 linked or left as typed in `corpus/link-edit/native-typed`.
+ #[test]
+ fn typed_urls_link_as_onenote_links_them() {
+ for (word, linked) in [
+ ("www.example.com", Some("www.example.com")),
+ ("http://example.org/a?b=1", Some("http://example.org/a?b=1")),
+ ("https://a.example/p.", Some("https://a.example/p")),
+ ("(http://b.example/q)", Some("http://b.example/q")),
+ ("ftp://c.example/f", Some("ftp://c.example/f")),
+ ("mailto:me@example.com", Some("mailto:me@example.com")),
+ ("me@example.com", None),
+ ("example.com", None),
+ (
+ "onenote:#Page§ion-id={x}",
+ Some("onenote:#Page§ion-id={x}"),
+ ),
+ ("file://c:/windows", Some("file://c:/windows")),
+ ("\\\\server\\share\\f", Some("\\\\server\\share\\f")),
+ ("news:comp.lang", Some("news:comp.lang")),
+ ("www.d.example,", Some("www.d.example")),
+ ("HTTP://E.EXAMPLE/UP", Some("HTTP://E.EXAMPLE/UP")),
+ (
+ "https://f.example/a_b-c~d%20e#frag",
+ Some("https://f.example/a_b-c~d%20e#frag"),
+ ),
+ ("wwWw.example.com", None),
+ ("http://", None),
+ ] {
+ assert_eq!(typed_url(word).map(|range| &word[range]), linked, "{word}");
+ }
+ }
+}
diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs
index 398a2ac57ff2d5583131a5714d57f11d8bb9b4bc..bc90efe1505d5de4288c21320a2d805d557d8429 100644
--- a/crates/canvas/src/editor/ops.rs
+++ b/crates/canvas/src/editor/ops.rs
@@ -596,6 +596,62 @@ mod tests {
assert_eq!((*target, range.clone()), (text(0), 1..4));
}
+ /// Alt+Shift+Up and Down swap a paragraph, children and all, with its sibling as moves,
+ /// keeping the caret on the moved text, and stop at the first and last sibling.
+ #[test]
+ fn moving_paragraphs_swaps_sibling_subtrees_as_move_ops() {
+ let mut engine = TextEngine::default();
+ let lines = ["A", "a", "B"].map(|line| Paragraph::new(line.into(), Format::default()));
+ let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
+ let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
+ editor
+ .select(
+ [TextPosition {
+ paragraph: 1,
+ offset: 0,
+ }; 2]
+ .into(),
+ )
+ .unwrap();
+ editor.indent(&mut engine, false).unwrap();
+ editor.take_ops().unwrap();
+ let texts = |editor: &CanvasEditor| {
+ editor.outlines()[0]
+ .document()
+ .nodes()
+ .iter()
+ .map(|node| (node.text().unwrap().text.text().to_owned(), node.level))
+ .collect::>()
+ };
+ let caret = TextPosition {
+ paragraph: 2,
+ offset: 1,
+ };
+ editor.select([caret; 2].into()).unwrap();
+ assert!(!editor.move_paragraphs(&mut engine, false).unwrap());
+ assert!(editor.move_paragraphs(&mut engine, true).unwrap());
+ let moved = [("B".into(), 1), ("A".into(), 1), ("a".into(), 2)];
+ assert_eq!(texts(&editor), moved);
+ assert_eq!(
+ editor.selection().positions,
+ [TextPosition {
+ paragraph: 0,
+ offset: 1
+ }; 2]
+ );
+ let ops = editor.take_ops().unwrap();
+ assert!(
+ !ops.is_empty() && ops.iter().all(|op| matches!(op, PageOp::Move { .. })),
+ "{ops:?}"
+ );
+ assert!(!editor.move_paragraphs(&mut engine, true).unwrap());
+ assert!(editor.move_paragraphs(&mut engine, false).unwrap());
+ assert_eq!(texts(&editor)[2], ("B".into(), 1));
+ assert_eq!(editor.selection().positions[0].paragraph, 2);
+ editor.undo(&mut engine).unwrap();
+ assert_eq!(texts(&editor), moved);
+ }
+
/// A change from elsewhere reaches only what it changed: a refresh whose stored page is
/// what the editor's ops left (though storage normalized it) changes nothing, history
/// included; one that changes an outline drops that outline's history and keeps the
diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs
index b9d6eaca1c37f063e7cee1b998561a9a1ba6b0f6..b4cbeadc39302d77886a0b25feb7eb8f83c812f5 100644
--- a/crates/canvas/src/editor/table.rs
+++ b/crates/canvas/src/editor/table.rs
@@ -273,8 +273,17 @@ impl CanvasEditor {
if self.active_outline().title {
return self.leave_title(engine);
}
+ let [anchor, focus] = self.active_outline().selection.positions;
+ if anchor == focus {
+ self.link_typed_url(engine, focus)?;
+ // Enter at a label's start splits before its field code.
+ self.leave_link_code()?;
+ if self.break_equation(engine)? {
+ return Ok(());
+ }
+ }
+ let [anchor, focus] = self.active_outline().selection.positions;
let outline = self.active_outline();
- let [anchor, focus] = outline.selection.positions;
let (cell, local, source) = outline
.document
.leaf(focus.paragraph)
diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs
index 6baef908a8cf2b4bfa10b1c756cb4521dd8bdbc3..69b412d1d79fac5c01be84f9b1255ec03740b5cb 100644
--- a/crates/canvas/src/gpu/mod.rs
+++ b/crates/canvas/src/gpu/mod.rs
@@ -33,6 +33,7 @@ impl Viewport {
origin: self.origin,
clip: None,
backdrop: None,
+ motion: None,
primitives,
}
}
@@ -151,6 +152,8 @@ const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
+const FLAG: &str = include_str!("../../assets/tags/flag.svg");
+const TAG: &str = include_str!("../../assets/tags/tag.svg");
/// The tag's artwork, drawn in order.
pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
@@ -168,6 +171,8 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
TagIcon::RedSquare => &[RED_SQUARE],
TagIcon::YellowSquare => &[YELLOW_SQUARE],
TagIcon::BlueSquare => &[BLUE_SQUARE],
+ TagIcon::Flag => &[FLAG],
+ TagIcon::Other => &[TAG],
}
}
@@ -190,7 +195,13 @@ mod tests {
#[test]
fn page_colours_colour_the_paper() {
let teal = 0x00f2f9d4;
- assert_eq!(Paper::WHITE.colored(Some(teal)).color.map(|c| (c * 1000.0).round()), colorref(teal).map(|c| (c * 1000.0).round()));
+ assert_eq!(
+ Paper::WHITE
+ .colored(Some(teal))
+ .color
+ .map(|c| (c * 1000.0).round()),
+ colorref(teal).map(|c| (c * 1000.0).round())
+ );
let dark = Paper {
color: draw::srgb(0x1f, 0x20, 0x22),
ink: draw::srgb(0xe6, 0xe6, 0xe6),
@@ -290,6 +301,8 @@ mod tests {
TagIcon::RedSquare,
TagIcon::YellowSquare,
TagIcon::BlueSquare,
+ TagIcon::Flag,
+ TagIcon::Other,
];
let text = TextEngine::default()
.layout(
@@ -322,6 +335,7 @@ mod tests {
origin: [2.0 + 16.0 * index as f32, 2.0],
size: crate::outline::ParagraphTag::SIZE,
tint: [1.0; 4],
+ palette: draw::Palette::default(),
})
.collect();
primitives.push(Primitive::Text {
diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs
index 54a130ac749e65c5ff21e46bf3880b85ae0d415d..13ff11522c8255cb0d721d31a8aa345aea7a4c89 100644
--- a/crates/canvas/src/gpu/page.rs
+++ b/crates/canvas/src/gpu/page.rs
@@ -838,7 +838,7 @@ impl crate::outline::OutlineLayout {
paragraph.origin[1] - OVERHANG,
paragraph.origin[1] + paragraph.text.height() + OVERHANG,
];
- paragraph.math.is_some() || (end >= rows[0] && start <= rows[1])
+ end >= rows[0] && start <= rows[1]
})
}
@@ -925,50 +925,50 @@ impl crate::outline::OutlineLayout {
bottom + origin[1],
]
});
- match ¶graph.math {
- Some(math) => {
- for item in &math.items {
- primitives.push(match item {
- crate::math::MathItem::Text { layout, origin } => Primitive::Text {
- clip,
- text: layout,
- origin: [x + origin[0], y + origin[1]],
- ink,
- },
- // As pen strokes, so hairline rules keep a device pixel.
- crate::math::MathItem::Rule([x0, y0, x1, y1]) => {
- let width = (x1 - x0).min(y1 - y0);
- let [from, to] = if x1 - x0 >= y1 - y0 {
- let middle = (y0 + y1) / 2.0;
- [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]]
- } else {
- let middle = (x0 + x1) / 2.0;
- [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]]
- };
- Primitive::Segment {
- from: [x + from[0], y + from[1]],
- to: [x + to[0], y + to[1]],
- width,
- round: false,
- color: math.color.map_or(ink, colorref),
- }
- }
- crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment {
+ primitives.push(Primitive::Text {
+ clip,
+ text: ¶graph.text,
+ origin: [x, y],
+ ink,
+ });
+ for (index, [left, baseline]) in paragraph.text.spaces() {
+ let math = ¶graph.math[index];
+ let [x, y] = [x + left, y + baseline - math.baseline];
+ for item in &math.items {
+ primitives.push(match item {
+ crate::math::MathItem::Text { layout, origin } => Primitive::Text {
+ clip,
+ text: layout,
+ origin: [x + origin[0], y + origin[1]],
+ ink,
+ },
+ // As pen strokes, so hairline rules keep a device pixel.
+ crate::math::MathItem::Rule([x0, y0, x1, y1]) => {
+ let width = (x1 - x0).min(y1 - y0);
+ let [from, to] = if x1 - x0 >= y1 - y0 {
+ let middle = (y0 + y1) / 2.0;
+ [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]]
+ } else {
+ let middle = (x0 + x1) / 2.0;
+ [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]]
+ };
+ Primitive::Segment {
from: [x + from[0], y + from[1]],
to: [x + to[0], y + to[1]],
- width: *width,
- round: true,
+ width,
+ round: false,
color: math.color.map_or(ink, colorref),
- },
- });
- }
+ }
+ }
+ crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment {
+ from: [x + from[0], y + from[1]],
+ to: [x + to[0], y + to[1]],
+ width: *width,
+ round: true,
+ color: math.color.map_or(ink, colorref),
+ },
+ });
}
- None => primitives.push(Primitive::Text {
- clip,
- text: ¶graph.text,
- origin: [x, y],
- ink,
- }),
}
for (layout, marker) in ¶graph.markers {
primitives.push(Primitive::Text {
@@ -987,6 +987,7 @@ impl crate::outline::OutlineLayout {
],
size: tag.size,
tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
+ palette: draw::Palette::default(),
});
}
}
@@ -1621,15 +1622,15 @@ mod tests {
}
#[test]
- fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
+ fn unsupported_outlines_and_titles_leave_supported_outlines_editable() {
use crate::document::TextDocument;
use onestore::document::{Kind, Tag};
use onestore::page::{Definition, Outline, Title};
for title in [false, true] {
- let definition = ExGuid {
- guid: [9; 16],
+ let [tag, list] = [9, 10].map(|byte| ExGuid {
+ guid: [byte; 16],
n: 1,
- };
+ });
let make_outline = |text: &str| {
let document =
TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
@@ -1650,9 +1651,26 @@ mod tests {
unsupported: Vec::new(),
}
};
- let mut tagged = make_outline("Tagged");
- tagged.paragraphs[0].tags.push(Tag {
- definition: Some(definition),
+ let mut numbered = make_outline("Numbered");
+ numbered.paragraphs[0].lists.push(list);
+ let unsupported = if title {
+ PageObject::Title(Title {
+ date: None,
+ id: ExGuid::default(),
+ layout: Layout {
+ x: Some(10.0),
+ y: Some(20.0),
+ ..Default::default()
+ },
+ outlines: vec![numbered],
+ })
+ } else {
+ PageObject::Outline(numbered)
+ };
+ // A tag symbol without artwork draws as a plain tag and stays editable.
+ let mut editable = make_outline("Editable");
+ editable.paragraphs[0].tags.push(Tag {
+ definition: Some(tag),
status: 1,
action_type: None,
created: None,
@@ -1662,40 +1680,41 @@ mod tests {
task_id: None,
extra_set: 0,
});
- let unsupported = if title {
- PageObject::Title(Title {
- date: None,
- id: ExGuid::default(),
- layout: Layout {
- x: Some(10.0),
- y: Some(20.0),
- ..Default::default()
- },
- outlines: vec![tagged],
- })
- } else {
- PageObject::Outline(tagged)
- };
let page = Page {
identity: None,
created: None,
title: String::new(),
margin_origin: [36.0, 14.0],
color: None,
- objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],
- definitions: BTreeMap::from([(
- definition,
- Definition {
- kind: Kind::TagDefinition {
- shape: Some(999),
- label: Some("Unknown icon".into()),
- color: None,
- highlight: None,
- action_type: None,
+ objects: vec![unsupported, PageObject::Outline(editable)],
+ definitions: BTreeMap::from([
+ (
+ tag,
+ Definition {
+ kind: Kind::TagDefinition {
+ shape: Some(999),
+ label: Some("Unknown icon".into()),
+ color: None,
+ highlight: None,
+ action_type: None,
+ },
+ format: Default::default(),
},
- format: Default::default(),
- },
- )]),
+ ),
+ (
+ // A numbering sequence OneNote 2010 does not offer.
+ list,
+ Definition {
+ kind: Kind::List {
+ font: None,
+ format: Some("\u{fffd}\u{63}.".into()),
+ restart: None,
+ bullet: None,
+ },
+ format: Default::default(),
+ },
+ ),
+ ]),
};
let mut engine = TextEngine::default();
let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs
index be6f94b53ae1f91ef08800cd53d1fa54632d864a..bcc2b16de694a12e407d267c374fcaaee663bb59 100644
--- a/crates/canvas/src/gpu/page/background.rs
+++ b/crates/canvas/src/gpu/page/background.rs
@@ -551,7 +551,10 @@ pub fn template_art(
[view.width() * 0.75, view.height() * 0.75]
});
let pixels = size.map(|side| (side * density).round().max(1.0) as u32);
- Some((rasterize(template.svg, dark(paper).then_some(paper), pixels), size))
+ Some((
+ rasterize(template.svg, dark(paper).then_some(paper), pixels),
+ size,
+ ))
}
/// Template `name`'s art as Snowbound stores it for a page: in the format and pixel size of
diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs
index 376e86aea00b31151277c27a905f9d2fc35b317a..33338090166679e6390f59929e3eb36fa1a3cacf 100644
--- a/crates/canvas/src/interaction/accessibility.rs
+++ b/crates/canvas/src/interaction/accessibility.rs
@@ -608,6 +608,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result
TagIcon::RedSquare => "Project A",
TagIcon::YellowSquare => "Project B",
TagIcon::BlueSquare => "Project C",
+ TagIcon::Flag => "Outlook task",
+ TagIcon::Other => "Tag",
};
let label = if tag.label.is_empty() {
fallback
diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs
index 45d14e0f66b662d76c851f0bd2be490abe0a8e93..01501877f17a72fd5cbf66faa9680f5396064b9f 100644
--- a/crates/canvas/src/interaction/mod.rs
+++ b/crates/canvas/src/interaction/mod.rs
@@ -53,8 +53,20 @@ pub enum Request {
Copy(String),
/// Read the clipboard and hand its text to `PageView::commit_text`.
Paste,
- /// Show the platform's character picker.
- CharacterPalette,
+ /// Open a link's address, as a click on a link does.
+ OpenLink(String),
+}
+
+/// What a secondary press lands on, which its context menu offers commands for.
+#[derive(Debug, Default, PartialEq)]
+pub struct Context {
+ /// The address of the link at the caret.
+ pub link: Option,
+ pub equation: bool,
+ /// Whether text is selected, which Cut and Copy take.
+ pub selected: bool,
+ /// The paragraph at the caret, which Copy Link to Paragraph names.
+ pub paragraph: Option,
}
/// What an event did: `changed` means the page or selection changed (the host saves,
@@ -167,10 +179,7 @@ impl ObjectFocus {
}
fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {
- key == &Key::Named(NamedKey::Escape)
- || (modifiers.control && key == &Key::Named(NamedKey::Tab))
- || (modifiers.command
- && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))
+ key == &Key::Named(NamedKey::Escape) || (modifiers.control && key == &Key::Named(NamedKey::Tab))
}
/// Where a page was left: its scroll and the focused outline's selection.
@@ -750,6 +759,11 @@ impl PageView {
(Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
(Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
(None, Some(Hit::Date(_))) => Cursor::Pointer,
+ (None, Some(Hit::Text { id, point }))
+ if self.editor.link_under(id, point).is_some() =>
+ {
+ Cursor::Pointer
+ }
(None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,
_ => Cursor::Text,
}
@@ -902,6 +916,17 @@ impl PageView {
grab,
});
}
+ Some(Hit::Text { id, point })
+ if unit == SelectionUnit::Grapheme
+ && !self.modifiers.shift
+ && let Some(address) = self.editor.link_under(id, point) =>
+ {
+ self.drag = None;
+ return Ok(Response {
+ request: Some(Request::OpenLink(address)),
+ ..Response::default()
+ });
+ }
Some(Hit::Text { id, point }) => {
let extend = self.modifiers.shift
&& self.object_focus.is_none()
@@ -1050,6 +1075,118 @@ impl PageView {
self.edited()
}
+ /// Copy, or Cut with `cut`: the selected text goes to the clipboard.
+ pub fn copy(&mut self, cut: bool) -> Result {
+ let [anchor, focus] = self.editor.selection().positions;
+ let selected = self
+ .editor
+ .active_outline()
+ .document()
+ .slice(anchor.min(focus)..anchor.max(focus))?;
+ let text = selected
+ .iter()
+ .map(|paragraph| {
+ paragraph
+ .project()
+ .map(|projection| projection.text().text().to_owned())
+ })
+ .collect::, _>>()?
+ .join("\n");
+ if text.is_empty() {
+ return Ok(Response::default());
+ }
+ if cut {
+ self.editor.insert(&mut self.engine, "")?;
+ }
+ Ok(Response {
+ request: Some(Request::Copy(text)),
+ ..self.edited()?
+ })
+ }
+
+ /// A secondary press at the pointer: text there takes the caret unless it lies in the
+ /// selection, as OneNote's context menu acts where it opens. `None` off text.
+ pub fn context(&mut self) -> Result