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> { + let point = self.viewport.document_point(self.pointer); + let Some(Hit::Text { id, point }) = self.hit_test(point) else { + return Ok(None); + }; + if self.object_focus.is_some() || !self.accepts_text() { + return Ok(None); + } + let outline = self.editor.active_outline().id; + let [anchor, focus] = self.editor.selection().positions; + self.editor.focus_outline(id)?; + let at = self + .editor + .selection_at(point[0], point[1], SelectionUnit::Grapheme)?; + let inside = outline == id + && anchor.min(focus) <= at.positions[0] + && at.positions[0] <= anchor.max(focus); + if !inside { + self.editor.select(at)?; + } + let [anchor, focus] = self.editor.selection().positions; + let context = Context { + link: self + .editor + .link_at(anchor.min(focus)) + .map(|link| link.target), + equation: self.editor.in_equation(), + selected: anchor != focus, + paragraph: self + .editor + .active_outline() + .document() + .leaf(anchor.min(focus).paragraph) + .map(|(_, _, node)| node.id), + }; + Ok(Some((self.changed()?, context))) + } + + /// The text and address the Link dialog opens with for the selection. + pub fn link_prefill(&self) -> (String, String) { + self.editor.link_prefill() + } + + /// OK in the Link dialog: links what [`Self::link_prefill`] picked, shown as `text`. + pub fn set_link(&mut self, text: &str, address: &str) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + self.editor.set_link(&mut self.engine, text, address)?; + self.edited() + } + + /// Remove Link, or Select Link with `select`, on the link at the caret. + pub fn unlink(&mut self, select: bool) -> Result { + if select { + self.editor.select_link()?; + } else { + self.editor.remove_link(&mut self.engine)?; + } + self.edited() + } + + /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`]. + pub fn insert_equation(&mut self) -> Result { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok(Response::default()); + } + self.editor.insert_equation(&mut self.engine)?; + self.edited() + } + + /// Professional, or Linear with `linear`, on the equation at the caret. + pub fn switch_equation(&mut self, linear: bool) -> Result { + if linear { + self.editor.linear_equation(&mut self.engine)?; + } else { + self.editor.build_equation(&mut self.engine)?; + } + self.edited() + } + /// Undoes the last edit, or redoes the last undone one. pub fn undo(&mut self, redo: bool) -> Result { if redo { @@ -1065,6 +1202,12 @@ impl PageView { self.edited() } + /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does. + pub fn widen_selection(&mut self) -> Result { + self.editor.widen_selection()?; + self.edited() + } + /// The date or time chosen after `Request::EditDate`. pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result { self.editor.change_date(&mut self.engine, timestamp, text)?; @@ -1113,11 +1256,8 @@ impl PageView { self.set_object_focus(None); return self.edited(); } - if let Some(focus) = self.object_focus { - let undo = matches!(focus, ObjectFocus::Image(_)) - && command - && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); - if !undo && !read_only_shortcut(key, self.modifiers) { + if self.object_focus.is_some() { + if !read_only_shortcut(key, self.modifiers) { return Ok(Response::default()); } if key == &Key::Named(NamedKey::Escape) { @@ -1125,12 +1265,6 @@ impl PageView { return self.edited(); } } - if command && control && key == &Key::Named(NamedKey::Space) { - return Ok(Response { - request: Some(Request::CharacterPalette), - ..Response::default() - }); - } if matches!( self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) @@ -1140,6 +1274,12 @@ impl PageView { return self.changed(); } } + if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers) + && self.editor.marked_range().is_none() + { + self.editor.move_paragraphs(&mut self.engine, up)?; + return self.edited(); + } if command && option { let delta = match key { Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), @@ -1195,124 +1335,75 @@ impl PageView { } return self.edited(); } - let mut request = None; - if command && let Key::Character(key) = key { - match key.to_lowercase().as_str() { - "n" if shift => { - let position = if let Some(focus) = self.object_focus { - let rect = self.object_rect(focus); - [rect[2] + 24.0, rect[1]] + // The host runs the shortcut modifier's chords from its command table. + if command && let Key::Character(_) = key { + return Ok(Response::default()); + } + let chord = Command::from_key(key, self.modifiers); + if let Some(Command::Move(movement)) = chord { + self.editor + .move_selection(&mut self.engine, movement, shift)?; + } else if let Some(Command::ScrollPage { up }) = chord { + // AppKit keeps ten points of the last page in view. + let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0); + self.viewport.origin[1] += if up { page } else { -page }; + return self.moved(); + } else if let Some(Command::MovePage { up }) = chord { + self.move_page(up, shift)?; + } else if self.editor.marked_range().is_none() { + match (chord, key) { + (Some(Command::DeleteTo(movement)), _) => { + self.editor.delete_to(&mut self.engine, movement)?; + } + (Some(Command::Delete { backward }), _) => { + self.editor.delete(&mut self.engine, backward)?; + } + (Some(Command::Kill), _) => { + if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? { + self.editor.delete(&mut self.engine, false)?; + } + } + (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => { + let limits = self.scroll(); + self.viewport.origin[1] = -if *end == NamedKey::Home { + limits.min[1] } else { - let bounds = self.editor.active_outline().bounds(); - [bounds.x1 as f32 + 24.0, bounds.y0 as f32] + limits.max[1] }; - self.editor.place_caret( - &mut self.engine, - snap_to_grid(position, self.editor.margin_origin()), - DEFAULT_OUTLINE_WIDTH, - )?; - self.set_object_focus(None); + return self.moved(); } - "a" => self.editor.select_all()?, - "z" => return self.undo(shift), - "y" if edit::Platform::CURRENT == edit::Platform::Windows => { - return self.undo(true); - } - "c" | "x" => { + (_, Key::Named(NamedKey::Enter)) => { + // Enter inside a link follows it, as OneNote's does. 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() { - if key.eq_ignore_ascii_case("x") { - self.editor.insert(&mut self.engine, "")?; - } - request = Some(Request::Copy(text)); - } - } - "v" => request = Some(Request::Paste), - "+" | "=" => { - self.zoom_about(1.1, self.pointer); - return self.moved(); - } - "-" => { - self.zoom_about(1.0 / 1.1, self.pointer); - return self.moved(); + if let Some(link) = self.editor.link_at(focus).filter(|link| { + anchor == focus + && !shift + && link.label.start < focus.offset + && focus.offset < link.label.end + }) { + return Ok(Response { + request: Some(Request::OpenLink(link.target)), + ..Response::default() + }); + } + self.editor.enter(&mut self.engine, shift)?; } - "0" => { - self.zoom_about(1.0 / self.zoom(), self.pointer); - return self.moved(); + (_, Key::Named(NamedKey::Tab)) => { + self.editor.tab(&mut self.engine, shift)?; } - _ => return Ok(Response::default()), - } - } else { - let chord = Command::from_key(key, self.modifiers); - if let Some(Command::Move(movement)) = chord { - self.editor - .move_selection(&mut self.engine, movement, shift)?; - } else if let Some(Command::ScrollPage { up }) = chord { - // AppKit keeps ten points of the last page in view. - let page = self.viewport.size[1] as f32 - 10.0 * self.display_scale; - self.viewport.origin[1] += if up { page } else { -page }; - return self.moved(); - } else if let Some(Command::MovePage { up }) = chord { - self.move_page(up, shift)?; - } else if self.editor.marked_range().is_none() { - match (chord, key) { - (Some(Command::DeleteTo(movement)), _) => { - self.editor.delete_to(&mut self.engine, movement)?; - } - (Some(Command::Delete { backward }), _) => { - self.editor.delete(&mut self.engine, backward)?; - } - (Some(Command::Kill), _) => { - if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? { - self.editor.delete(&mut self.engine, false)?; - } - } - (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => { - let limits = self.scroll(); - self.viewport.origin[1] = -if *end == NamedKey::Home { - limits.min[1] - } else { - limits.max[1] - }; - return self.moved(); - } - (_, Key::Named(NamedKey::Enter)) => { - self.editor.enter(&mut self.engine, shift)?; - } - (_, Key::Named(NamedKey::Tab)) => { - self.editor.tab(&mut self.engine, shift)?; - } - _ if !command && !control => { - if let Some(text) = text - .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) - { - self.editor.insert(&mut self.engine, text)?; - } + _ if !command && !control => { + if let Some(text) = + text.filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) + { + self.editor.insert(&mut self.engine, text)?; } - _ => {} } - } else if key == &Key::Named(NamedKey::Escape) { - self.editor.cancel_composition(&mut self.engine)?; + _ => {} } + } else if key == &Key::Named(NamedKey::Escape) { + self.editor.cancel_composition(&mut self.engine)?; } - Ok(Response { - request, - ..self.edited()? - }) + self.edited() } } @@ -1526,7 +1617,9 @@ fn page_primitives<'a>( Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, _ => outline.origin(), }; - if (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id) + // OneNote frames the title whether or not it is focused or hovered. + if outline.title + || (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id) || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) { @@ -1860,8 +1953,10 @@ fn append_outline_chrome( if outline.title { primitives.push(Primitive::RoundedRect { rect: [left, top, right, bottom], - radius: [6.0 * pixel, (bottom - top) * 0.5], - stroke: Some(Stroke::Dashed(pixel)), + radius: [4.5, (bottom - top) * 0.5], + // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel + // when zoomed out. + stroke: Some(Stroke::Dashed(pixel.max(0.75))), color: paper.shade(crate::gpu::colorref(0x007f7f7f)), }); return; diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 4e28a644281fff8039023a448db759afd42eb43b..4cc9029e648cbfca39d0f0f094ddcad66a2a8303 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -171,7 +171,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { }) .collect::>(); assert_eq!(borders, [Some(Stroke::Dashed(1.0))]); - assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 6.0 && radius[1] * 2.0 == rect[3] - rect[1]))); + assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 4.5 && radius[1] * 2.0 == rect[3] - rect[1]))); let mut access = accessibility::Accessibility::default(); let update = access .update( @@ -192,6 +192,49 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { && node.label() == Some("Page title"))); } +#[test] +fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() { + let mut engine = TextEngine::default(); + let mut outline = |text: &str, origin, title| { + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(), + 468.0, + origin, + ) + .unwrap() + .snapshot(); + source.title = title; + TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap() + }; + let title = outline("Title", [36.0, 14.4], true); + let body = outline("Body", [36.0, 120.0], false); + let body_id = body.id; + let mut editor = + CanvasEditor::from_text_outlines(vec![title, body], Default::default(), None).unwrap(); + editor.focus_outline(body_id).unwrap(); + // OneNote 2010: a 4 px dash period at 100% and below, 4 px times the zoom above it. + for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] { + let scale = zoom * 96.0 / 72.0; + let primitives = + page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap(); + let dashed = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::RoundedRect { + stroke: Some(Stroke::Dashed(width)), + .. + } => Some(*width), + _ => None, + }) + .collect::>(); + assert!( + matches!(dashed[..], [width] if (width - pen).abs() < 1e-5), + "zoom {zoom}: {dashed:?}" + ); + } +} + #[test] fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { let mut engine = TextEngine::default(); @@ -1339,45 +1382,9 @@ fn read_only_focus_accepts_navigation_without_edit_shortcuts() { ..Modifiers::default() }, ), - ( - Key::Character("N".into()), - Modifiers { - command: true, - shift: true, - ..Modifiers::default() - }, - ), - ( - Key::Character("+".into()), - Modifiers { - command: true, - ..Modifiers::default() - }, - ), - ( - Key::Character("0".into()), - Modifiers { - command: true, - ..Modifiers::default() - }, - ), - ( - Key::Character("-".into()), - Modifiers { - command: true, - ..Modifiers::default() - }, - ), ] { assert!(read_only_shortcut(&key, modifiers)); } - assert!(!read_only_shortcut( - &Key::Character("n".into()), - Modifiers { - command: true, - ..Modifiers::default() - } - )); assert!(!read_only_shortcut( &Key::Named(NamedKey::Tab), Modifiers::default() @@ -1611,7 +1618,8 @@ fn events_route_through_the_view_as_the_host_delivers_them() { .starts_with('X') ); - // Command-C asks the host to copy; Command-V asks it to paste, then commits its text. + // Copy asks the host to copy; the host's paste commits its text. The shortcut + // modifier's chords are the host's, so the page takes none of them as typing. let _ = view .modifiers_changed(Modifiers { shift: true, @@ -1619,25 +1627,49 @@ fn events_route_through_the_view_as_the_host_delivers_them() { }) .unwrap(); let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); - let _ = view - .modifiers_changed(Modifiers { - command: true, - ..Modifiers::default() - }) - .unwrap(); assert_eq!( - view.key(&Key::Character("c".into()), None).unwrap().request, + view.copy(false).unwrap().request, Some(Request::Copy("B".into())) ); + let _ = view + .modifiers_changed(Modifiers { + command: true, + ..Modifiers::default() + }) + .unwrap(); assert_eq!( - view.key(&Key::Character("v".into()), None).unwrap().request, - Some(Request::Paste) + view.key(&Key::Character("v".into()), Some("v")).unwrap(), + Response::default() ); assert!(view.commit_text("line\nbreak".into()).unwrap().changed); assert_eq!( view.editor.active_outline().document().paragraphs().count(), 3 ); + + // Select All selects the caret's paragraph, then the whole outline. + let caret = view.editor.selection().positions[1].paragraph; + let select_all = |view: &mut PageView| { + let _ = view.widen_selection().unwrap(); + view.editor + .selection() + .positions + .map(|position| (position.paragraph, position.offset)) + }; + let last = view + .editor + .active_outline() + .document() + .paragraphs() + .last() + .unwrap(); + let whole = [(0, 0), (2, last.utf16_offset(last.text().len()).unwrap())]; + let paragraph = select_all(&mut view); + assert!( + paragraph[0] == (caret, 0) && paragraph != whole, + "{paragraph:?}" + ); + assert_eq!(select_all(&mut view), whole); let _ = view.modifiers_changed(Modifiers::default()).unwrap(); // A click on the picture selects it, stops text input, and Delete removes it. diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 3d98e60e3d002ec3366cee2ba7a524f1860a75c8..4e06409eef987747230d123d3ba218c2b302be2a 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -62,6 +62,18 @@ pub struct TextLayout { id: u64, pub(crate) shaped: Arc>, lines: Vec, + /// Where each inline space lies: its index in the spaces laid out, its x, and its line. + spaces: Vec<(usize, f32, usize)>, +} + +/// Room kept in a line for something drawn inline, such as an equation, before the text at +/// byte `index`; the line grows to its ascent and descent. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InlineSpace { + pub index: usize, + pub width: f32, + pub ascent: f32, + pub descent: f32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,6 +267,7 @@ impl TextEngine { &mut self, paragraph: &Paragraph, width: f32, + spaces: &[InlineSpace], ) -> Result, LayoutError> { if !width.is_finite() || width <= 0.0 { return Err(LayoutError::InvalidWidth); @@ -335,6 +348,16 @@ impl TextEngine { } start = span.end; } + for (id, space) in spaces.iter().enumerate() { + builder.push_inline_box(parley::InlineBox { + id: id as u64, + kind: parley::InlineBoxKind::InFlow, + index: space.index, + width: space.width, + height: space.ascent + space.descent, + baseline: Some(space.ascent), + }); + } let mut shaped = builder.build(text); shaped.break_all_lines(Some(width)); let alignment = match paragraph.spans()[0].format.alignment { @@ -362,6 +385,19 @@ impl TextEngine { f32::from(hhea.ascender().to_i16()) * scale, -f32::from(hhea.descender().to_i16()) * scale, ) + } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) + && head.units_per_em() != 0 + && font + .table_data(skrifa::raw::types::Tag::new(b"MATH")) + .is_some() + { + // A math font's Windows extents reach its tallest operators; OneNote sets linear + // math in lines of text height. + let scale = run.font_size() / f32::from(head.units_per_em()); + ( + f32::from(os2.s_typo_ascender()) * scale, + -f32::from(os2.s_typo_descender()) * scale, + ) } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) && head.units_per_em() != 0 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) @@ -378,7 +414,18 @@ impl TextEngine { } pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result { - let shaped = self.shape(paragraph, width)?; + self.layout_with(paragraph, width, &[]) + } + + /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. + pub fn layout_with( + &mut self, + paragraph: &Paragraph, + width: f32, + spaces: &[InlineSpace], + ) -> Result { + let shaped = self.shape(paragraph, width, spaces)?; + let mut placed = Vec::new(); let text = paragraph.text(); let mut lines = Vec::with_capacity(shaped.len()); let mut top = 0.0_f64; @@ -417,7 +464,7 @@ impl TextEngine { .unwrap_or_else(|| paragraph.spans().last().unwrap()) .format; let sample = - self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX)?; + self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX, &[])?; sample.lines().flat_map(|line| line.runs()).try_fold( (0.0_f32, 0.0_f32), |(a, d), run| { @@ -434,6 +481,14 @@ impl TextEngine { ascent = ascent.max(a); descent = descent.max(d); } + for item in line.items() { + if let PositionedLayoutItem::InlineBox(inline) = item { + let space = &spaces[inline.id as usize]; + ascent = ascent.max(space.ascent); + descent = descent.max(space.descent); + placed.push((inline.id as usize, inline.x, lines.len())); + } + } let height = ascent + descent; if height <= 0.0 || !height.is_finite() { return Err(LayoutError::InvalidFontMetrics); @@ -460,6 +515,7 @@ impl TextEngine { id, shaped: Arc::new(shaped), lines, + spaces: placed, }) } } @@ -491,6 +547,13 @@ impl TextLayout { Ok(()) } + /// Each inline space laid out, by its index, with its x and its line's baseline. + pub fn spaces(&self) -> impl Iterator + '_ { + self.spaces + .iter() + .map(|&(id, x, line)| (id, [x, self.lines[line].baseline])) + } + pub fn lines(&self) -> impl Iterator, &LineBox)> { self.shaped.lines().zip(&self.lines) } diff --git a/crates/canvas/src/math.rs b/crates/canvas/src/math.rs index 5fc062808aa5b7319cdf74c520c6c2a23198691d..ce90e57d16e219567a9555baa2cefd49baecf3a7 100644 --- a/crates/canvas/src/math.rs +++ b/crates/canvas/src/math.rs @@ -590,6 +590,24 @@ fn ink(layout: &TextLayout) -> [f32; 2] { extent.map(|v| if v.is_finite() { v } else { 0.0 }) } +/// The byte ranges of `paragraph`'s runs of math holding objects, which draw in two +/// dimensions; math without objects is text. +pub(crate) fn built(paragraph: &Paragraph) -> Vec> { + let mut zones: Vec> = Vec::new(); + let mut start = 0; + for span in paragraph.spans() { + if span.format.math == Some(true) { + match zones.last_mut() { + Some(zone) if zone.end == start => zone.end = span.end, + _ => zones.push(start..span.end), + } + } + start = span.end; + } + zones.retain(|zone| paragraph.text()[zone.clone()].contains('\u{fdd0}')); + zones +} + /// Lays out an equation paragraph at the font size and colour of its first run. pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result { let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?; @@ -612,8 +630,10 @@ pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result, /// The number the next numbered sibling at the node's level continues from. - count: Option, + count: Option, } /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. @@ -196,13 +197,13 @@ pub struct ParagraphLayout { pub markers: Vec<(TextLayout, [f32; 2])>, /// A numbered paragraph's number, and whether its list restarts the count instead of /// continuing from the previous sibling's. - pub(crate) number: Option<(u32, bool)>, + pub(crate) number: Option<(Count, bool)>, pub tags: Vec, /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the /// table cell holding a cell's top-level paragraph. pub(crate) parent: Option, - /// An equation draws in two dimensions in place of its linear text. - pub math: Option, + /// The equations drawn in two dimensions in spaces of the text, in order. + pub math: Vec, /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. pub band: Option, @@ -210,7 +211,9 @@ pub struct ParagraphLayout { #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum TagIcon { - CheckBox { checked: bool }, + CheckBox { + checked: bool, + }, Star, Question, Highlight, @@ -222,6 +225,10 @@ pub enum TagIcon { RedSquare, YellowSquare, BlueSquare, + /// An Outlook task flag. + Flag, + /// A tag symbol without artwork of its own. + Other, } #[derive(Clone, Debug, PartialEq)] @@ -270,6 +277,8 @@ impl ParagraphTag { /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. const MARKER_OFFSET: f32 = 3.9; +/// Points an equation's linear text lays out at, holding the caret without showing. +const HELD: f32 = 0.01; /// A one-run paragraph standing in for an object's caption, so it lays out through the same /// shaping (and caching) as the outline's text. @@ -327,20 +336,38 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result) -> u32 { - previous.map_or(1, |number| number.saturating_add(1)) +/// A numbered paragraph's number and its NumberListFormat. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Count { + pub(crate) number: u32, + format: Arc, +} + +/// The number after a previous sibling's in the same `format`, or the first. A number in +/// another format starts again at 1 even where one in this format came before it; siblings +/// without a number leave the count alone (`evidence/toolbar-17/restart.txt`). +fn next(previous: Option<&Count>, format: &str) -> u32 { + previous + .filter(|count| &*count.format == format) + .map_or(1, |count| count.number.saturating_add(1)) } /// The number a paragraph laid out as `number` takes once it follows `previous`. -fn follows(number: Option<(u32, bool)>, previous: Option) -> Option { - number.map(|(number, restart)| if restart { number } else { next(previous) }) +fn follows(number: Option<&(Count, bool)>, previous: Option<&Count>) -> Option { + number.map(|(count, restart)| Count { + number: if *restart { + count.number + } else { + next(previous, &count.format) + }, + format: count.format.clone(), + }) } /// What the numbered sibling after `node`, numbered `number`, continues from: siblings without /// a number leave the count alone, as do empty numbered ones after another number /// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`). -fn tally(node: &PageParagraph, number: Option, previous: Option) -> Option { +fn tally(node: &PageParagraph, number: Option, previous: Option) -> Option { number .filter(|_| { previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty()) @@ -350,7 +377,7 @@ fn tally(node: &PageParagraph, number: Option, previous: Option) -> Op /// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest /// paragraph at each level, and the previous sibling's at `level`, returning that one's count. -fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { +fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { siblings.pop(); } @@ -360,8 +387,56 @@ fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { } } +/// `text` in `font` as a Unicode font shows it. Windows' Symbol font is symbol-encoded, so +/// its bytes map through the Adobe Symbol encoding (a bullet is `U+00B7` there); Wingdings' +/// map into the private use area their symbol cmaps cover, which also keeps a shaper from +/// dropping `U+00AD` as a soft hyphen. +pub fn symbol_text(font: &str, text: &str) -> String { + /// The Adobe Symbol encoding from 0x20 and from 0xA0, after Unicode's `SYMBOL.TXT`. + const SYMBOL: [&str; 2] = [ + " !∀#∃%&∋()∗+,−./0123456789:;<=>?≅ΑΒΧΔΕΦΓΗΙϑΚΛΜΝΟΠΘΡΣΤΥςΩΞΨΖ[∴]⊥_‾αβχδεφγηιϕκλμνοπθρστυϖωξψζ{|}∼", + "€ϒ′≤⁄∞ƒ♣♦♥♠↔←↑→↓°±″≥×∝∂•÷≠≡≈…⏐⎯↵ℵℑℜ℘⊗⊕∅∩∪⊃⊇⊄⊂⊆∈∉∠∇®©™∏√⋅¬∧∨⇔⇐⇑⇒⇓◊〈®©™∑⎛⎜⎝⎡⎢⎣⎧⎨⎩⎪\u{f8ff}〉∫⌠⎮⌡⎞⎟⎠⎤⎥⎦⎫⎬⎭", + ]; + if font.eq_ignore_ascii_case("Symbol") { + text.chars() + .map(|c| { + let (range, from) = match u32::from(c) { + code @ 0x20..0x7f => (SYMBOL[0], code - 0x20), + code @ 0xa0..0xff => (SYMBOL[1], code - 0xa0), + _ => return c, + }; + range.chars().nth(from as usize).unwrap_or(c) + }) + .collect() + } else if font.to_ascii_lowercase().starts_with("wingdings") { + text.chars() + .map(|c| match u32::from(c) { + code @ 0x20..0x100 => char::from_u32(0xf000 + code).unwrap_or(c), + _ => c, + }) + .collect() + } else { + text.to_owned() + } +} + +/// The marker NumberListFormat `format` shows for `number`. +pub fn numbered(format: &str, number: u32) -> Result { + let (prefix, rest) = format + .split_once('\u{fffd}') + .ok_or(LayoutError::InvalidList)?; + let mut rest = rest.chars(); + Ok(format!( + "{prefix}{}{}", + numeral(rest.next(), number)?, + rest.as_str() + )) +} + /// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4 -/// upper and lower letters, as OneNote 2010's `numberSequence`. +/// upper and lower letters, 5 ordinal, 6 and 7 cardinal and ordinal words, 22 arabic of two +/// digits at least, as OneNote 2010's `numberSequence` spells them in English +/// (`evidence/toolbar-17/sequences.txt`). pub(crate) fn numeral(sequence: Option, number: u32) -> Result { let roman = |number: u32| { const DIGITS: [(u32, &str); 13] = [ @@ -379,7 +454,9 @@ pub(crate) fn numeral(sequence: Option, number: u32) -> Result, number: u32) -> Result()) }; match sequence.map(u32::from) { Some(0) => Ok(number.to_string()), + Some(22) => Ok(format!("{number:02}")), Some(1) => roman(number), Some(2) => roman(number).map(|text| text.to_lowercase()), Some(3) => letter(number), Some(4) => letter(number).map(|text| text.to_lowercase()), + Some(5) => { + let suffix = match (number % 10, number % 100) { + (_, 11..=13) => "th", + (1, _) => "st", + (2, _) => "nd", + (3, _) => "rd", + _ => "th", + }; + Ok(format!("{number}{suffix}")) + } + Some(sequence @ (6 | 7)) => { + let mut words = cardinal(number)?; + if sequence == 7 { + // The last word turns ordinal: one to first, twenty to twentieth. + let at = words.rfind([' ', '-']).map_or(0, |at| at + 1); + let last = &words[at..]; + let ordinal = match last { + "one" => "first".to_owned(), + "two" => "second".to_owned(), + "three" => "third".to_owned(), + "five" => "fifth".to_owned(), + "eight" => "eighth".to_owned(), + "nine" => "ninth".to_owned(), + "twelve" => "twelfth".to_owned(), + _ if last.ends_with('y') => format!("{}ieth", &last[..last.len() - 1]), + _ => format!("{last}th"), + }; + words.replace_range(at.., &ordinal); + } + let mut chars = words.chars(); + let first = chars.next().unwrap_or_default(); + Ok(first.to_uppercase().chain(chars).collect()) + } + _ => Err(LayoutError::UnsupportedContent), + } +} + +/// `number` in lowercase English words, "one hundred twenty-one", below a million. +fn cardinal(number: u32) -> Result { + const SMALL: [&str; 20] = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + ]; + const TENS: [&str; 10] = [ + "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", + ]; + fn below_thousand(number: u32) -> String { + let (hundreds, rest) = (number / 100, number % 100); + let rest = match rest { + 0 => None, + 1..20 => Some(SMALL[rest as usize].to_owned()), + _ if rest % 10 == 0 => Some(TENS[(rest / 10) as usize].to_owned()), + _ => Some(format!( + "{}-{}", + TENS[(rest / 10) as usize], + SMALL[(rest % 10) as usize] + )), + }; + match (hundreds, rest) { + (0, rest) => rest.unwrap_or_default(), + (hundreds, None) => format!("{} hundred", SMALL[hundreds as usize]), + (hundreds, Some(rest)) => format!("{} hundred {rest}", SMALL[hundreds as usize]), + } + } + match number { + 1..1000 => Ok(below_thousand(number)), + 1000..1_000_000 => { + let (thousands, rest) = (number / 1000, number % 1000); + let head = format!("{} thousand", below_thousand(thousands)); + Ok(if rest == 0 { + head + } else { + format!("{head} {}", below_thousand(rest)) + }) + } _ => Err(LayoutError::UnsupportedContent), } } @@ -438,9 +611,6 @@ impl ParagraphLayout { } fn size(&self) -> [f32; 2] { - if let Some(math) = &self.math { - return [self.origin[0] + math.size[0], math.size[1]]; - } [ self.origin[0] + self.text.shaped.width(), self.markers @@ -454,7 +624,7 @@ impl ParagraphLayout { pub(crate) fn shape( engine: &mut TextEngine, paragraph: &PageParagraph, - previous: Option, + previous: Option<&Count>, width: f32, indents: &[f32], definitions: &BTreeMap, @@ -474,53 +644,111 @@ impl ParagraphLayout { return Err(LayoutError::InvalidSpacing); } // Stored newest first; OneNote lists and paints tags oldest first. - let mut tag_definitions = paragraph + // An Outlook task tag carries no definition; a tag whose definition is missing shows + // nothing, as OneNote draws no icon for it. + let mut tag_definitions: Vec<_> = paragraph .tags .iter() .chain(&source.tags) - .map( - |tag| match tag.definition.as_ref().and_then(|id| definitions.get(id)) { - Some(Definition { - kind: - Kind::TagDefinition { - shape, - label, - color, - highlight, - .. - }, + .filter_map(|tag| { + if tag.status & 4 != 0 { + return Some((tag, None, None, None, None)); + } + match &definitions.get(tag.definition.as_ref()?)?.kind { + Kind::TagDefinition { + shape, + label, + color, + highlight, .. - }) if tag.status & 4 == 0 => Ok((tag, *shape, label, *color, *highlight)), - _ => Err(LayoutError::UnsupportedContent), - }, - ) - .collect::, _>>()?; + } => Some((tag, Some(*shape), label.as_deref(), *color, *highlight)), + _ => None, + } + }) + .collect(); tag_definitions.reverse(); // The newest tag that sets a colour wins. let color = tag_definitions.iter().rev().find_map(|tag| tag.3); let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4); - let equation = onestore::page::Math::is_equation(&source.text); - let mut text = if color.is_none() && highlight.is_none() && !equation { - engine.layout(projection.text(), width - indent)? + // Equations with objects draw in two dimensions in a space kept in their line; their + // linear text stays in the text, too small to see, to hold the caret. + let visible = projection.text(); + let mut equations = Vec::new(); + for zone in crate::math::built(visible) { + let units = |byte| { + visible + .utf16_offset(byte) + .map_err(|_| LayoutError::InvalidSourceRange) + }; + let math = visible + .slice(units(zone.start)?..units(zone.end)?) + .map_err(|_| LayoutError::InvalidSourceRange)?; + // An equation that does not parse shows its linear text. + if let Ok(math) = crate::math::layout(engine, &math) { + equations.push((zone, math)); + } + } + let spaces: Vec<_> = equations + .iter() + .map(|(zone, math)| InlineSpace { + index: zone.start, + width: math.size[0], + ascent: math.baseline, + descent: math.size[1] - math.baseline, + }) + .collect(); + // URL text shows as a link, as OneNote links it on opening a page. + let mut marks: Vec<(Range, bool)> = crate::editor::shown_urls(visible) + .into_iter() + .map(|url| (url, false)) + .chain(equations.iter().map(|(zone, _)| (zone.clone(), true))) + .collect(); + marks.sort_by_key(|(range, _)| range.start); + let mut text = if color.is_none() && highlight.is_none() && marks.is_empty() { + engine.layout(visible, width - indent)? } else { - let visible = projection.text(); let mut start = 0; - let runs = visible.spans().iter().map(|span| { + let runs = visible.spans().iter().flat_map(|span| { let mut format = span.format.clone(); format.color = color.or(format.color); format.highlight = highlight.or(format.highlight); - // An equation draws in two dimensions; its linear text only holds the caret, - // which the body font sizes like a line of text. - if equation { - format.font = None; + let mut runs = Vec::new(); + let mut from = start; + for (range, equation) in &marks { + let (a, b) = ( + range.start.clamp(from, span.end), + range.end.clamp(from, span.end), + ); + if a < b { + runs.push((visible.text()[from..a].to_owned(), format.clone())); + let marked = if *equation { + Format { + font_size: Some(HELD), + superscript: None, + subscript: None, + ..format.clone() + } + } else { + Format { + hyperlink: Some(true), + ..format.clone() + } + }; + runs.push((visible.text()[a..b].to_owned(), marked)); + from = b; + } } - let run = (visible.text()[start..span.end].to_owned(), format); + runs.push((visible.text()[from..span.end].to_owned(), format)); start = span.end; - run + runs }); - engine.layout( - &Paragraph::from_runs(runs.collect::>()), + engine.layout_with( + &Paragraph::from_runs( + runs.filter(|(text, _)| !text.is_empty()) + .collect::>(), + ), width - indent, + &spaces, )? }; let mut markers = Vec::new(); @@ -538,34 +766,39 @@ impl ParagraphLayout { return Err(LayoutError::InvalidList); }; let mut color = definition.format.color; - let value = match value.split_once('\u{fffd}') { - Some((prefix, rest)) => { - // An empty numbered paragraph shows its number as a grey placeholder. - if source.text.text().is_empty() { - color = Some(PLACEHOLDER); - } - let current = restart.unwrap_or(next(previous)); - number = Some((current, restart.is_some())); - let mut rest = rest.chars(); - format!( - "{prefix}{}{}", - numeral(rest.next(), current)?, - rest.as_str() - ) + let value = if value.contains('\u{fffd}') { + // An empty numbered paragraph shows its number as a gray placeholder. + if source.text.text().is_empty() { + color = Some(PLACEHOLDER); } - None => value.clone(), + let current = restart.unwrap_or(next(previous, value)); + let format = value.as_str().into(); + number = Some(( + Count { + number: current, + format, + }, + restart.is_some(), + )); + numbered(value, current)? + } else { + value.clone() }; if value.contains('\u{fffd}') { return Err(LayoutError::UnsupportedContent); } + // Without a font of its own a marker takes its text's, as a number does. + let font = font + .clone() + .or_else(|| definition.format.font.clone()) + .or_else(|| format.font.clone()); + let value = font + .as_deref() + .map_or(value.clone(), |font| symbol_text(font, &value)); let marker = Paragraph::new( value, Format { - // Without a font of its own a marker takes its text's, as a number does. - font: font - .clone() - .or_else(|| definition.format.font.clone()) - .or_else(|| format.font.clone()), + font, font_size: definition.format.font_size.or(format.font_size), color, ..Format::default() @@ -589,22 +822,24 @@ impl ParagraphLayout { for tag in &tag_definitions { let (tag, shape, label) = (tag.0, tag.1, tag.2); let icon = match shape { - Some(0) => continue, - Some(3) => TagIcon::CheckBox { + None => TagIcon::Flag, + Some(Some(0) | None) => continue, + Some(Some(3)) => TagIcon::CheckBox { checked: tag.status & 1 != 0, }, - Some(13) => TagIcon::Star, - Some(15) => TagIcon::Question, - Some(17) => TagIcon::Exclamation, - Some(18) => TagIcon::Phone, - Some(23) => TagIcon::Address, - Some(118) => TagIcon::Contact, - Some(136) => TagIcon::Highlight, - Some(100) => TagIcon::RedSquare, - Some(101) => TagIcon::YellowSquare, - Some(102) => TagIcon::BlueSquare, - Some(121) => TagIcon::Music, - _ => return Err(LayoutError::UnsupportedContent), + Some(Some(13)) => TagIcon::Star, + Some(Some(15)) => TagIcon::Question, + Some(Some(17)) => TagIcon::Exclamation, + Some(Some(18)) => TagIcon::Phone, + Some(Some(23)) => TagIcon::Address, + Some(Some(118)) => TagIcon::Contact, + Some(Some(136)) => TagIcon::Highlight, + Some(Some(100)) => TagIcon::RedSquare, + Some(Some(101)) => TagIcon::YellowSquare, + Some(Some(102)) => TagIcon::BlueSquare, + Some(Some(121)) => TagIcon::Music, + // A symbol without artwork here shows as a plain tag. + Some(_) => TagIcon::Other, }; let size = format.font_size.unwrap_or(11.0); let side = ParagraphTag::side(size); @@ -612,13 +847,11 @@ impl ParagraphLayout { icon, origin: [0.0, ParagraphTag::top(baseline, size, side)], size: side, - label: label.clone().unwrap_or_default(), + label: label.unwrap_or_default().to_owned(), disabled: tag.status & 2 != 0, }); } - let math = equation - .then(|| crate::math::layout(engine, &source.text)) - .transpose()?; + let math = equations.into_iter().map(|(_, math)| math).collect(); let mut result = Self { id: paragraph.id, origin: [indent, 0.0], @@ -732,7 +965,7 @@ impl OutlineLayout { edit: Option<&DocumentEdit>, shape: &mut impl FnMut( &PageParagraph, - Option, + Option<&Count>, f32, &[f32], ) -> Result, @@ -779,10 +1012,10 @@ impl OutlineLayout { edit: Option<&DocumentEdit>, mut hiding: BTreeSet, mut state: (f64, Option), - siblings: &mut Vec<(u32, Option)>, + siblings: &mut Vec<(u32, Option)>, shape: &mut impl FnMut( &PageParagraph, - Option, + Option<&Count>, f32, &[f32], ) -> Result, @@ -820,8 +1053,8 @@ impl OutlineLayout { if !hidden { let (space, height, flow, extent) = match &node.content { ParagraphContent::Text(_) => { - let mut paragraph = shape(node, previous, width, indents)?; - number = paragraph.number.map(|(number, _)| number); + let mut paragraph = shape(node, previous.as_ref(), width, indents)?; + number = paragraph.number.clone().map(|(count, _)| count); let space = spacing(¶graph.projection.text().spans()[0].format)?; paragraph.origin[1] = top(&mut state, space); let size = paragraph.size(); @@ -951,7 +1184,7 @@ impl OutlineLayout { } } block.count = tally(node, number, previous); - siblings.push((node.level, block.count)); + siblings.push((node.level, block.count.clone())); block.state = state; if depth == 0 { result.blocks.push(block); @@ -963,10 +1196,10 @@ impl OutlineLayout { } /// Root node `root`'s number, when it is numbered text on show. - fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<(u32, bool)> { + fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<&(Count, bool)> { let block = &self.blocks[root]; (block.metrics.is_some() && nodes[root].text().is_some()) - .then(|| self.paragraphs[block.first[0]].number) + .then(|| self.paragraphs[block.first[0]].number.as_ref()) .flatten() } @@ -996,7 +1229,7 @@ impl OutlineLayout { fixed_width: bool, shape: &mut impl FnMut( &PageParagraph, - Option, + Option<&Count>, f32, &[f32], ) -> Result, @@ -1047,7 +1280,7 @@ impl OutlineLayout { for root in (0..range.start).rev() { if nodes[root].level < level { level = nodes[root].level; - siblings.push((level, self.blocks[root].count)); + siblings.push((level, self.blocks[root].count.clone())); if level <= 1 { break; } @@ -1067,7 +1300,8 @@ impl OutlineLayout { let indent = indentation(node.level, indents, width)?; if let Some((source, cached)) = cached.get(&node.id) && *source == node - && follows(cached.number, previous) == cached.number.map(|(n, _)| n) + && follows(cached.number.as_ref(), previous) + == cached.number.clone().map(|(count, _)| count) && cached.text.shaped.layout_max_advance() == width - indent && cached .markers @@ -1104,9 +1338,11 @@ impl OutlineLayout { for root in range.end..nodes.len() { let previous = sibling(&mut siblings, nodes[root].level); let number = self.number(nodes, root); - let now = follows(number, previous); - let counted = tally(&nodes[root], now, previous); - if now != number.map(|(number, _)| number) || counted != self.blocks[root].count { + let now = follows(number, previous.as_ref()); + let counted = tally(&nodes[root], now.clone(), previous); + if now.as_ref() != number.map(|(count, _)| count) + || counted != self.blocks[root].count + { renumbered = root + 1; } siblings.push((nodes[root].level, counted)); @@ -1275,7 +1511,7 @@ impl OutlineLayout { edit: Option<&DocumentEdit>, shape: &mut impl FnMut( &PageParagraph, - Option, + Option<&Count>, f32, &[f32], ) -> Result, @@ -1885,17 +2121,26 @@ mod tests { unreachable!() }; *shape = Some(999); - assert!(matches!( + let mut shaped = |node: &PageParagraph, definitions| { ParagraphLayout::shape( &mut engine, - &node, + node, None, 120.0, &[18.0, 0.0, 27.0], - &definitions - ), - Err(LayoutError::UnsupportedContent) - )); + definitions, + ) + .unwrap() + }; + // A symbol without artwork shows as a plain tag, an Outlook task as its flag, and a + // tag whose definition is missing as nothing. + assert_eq!(shaped(&node, &definitions).tags[0].icon, TagIcon::Other); + let mut task = node.clone(); + task.text_mut().unwrap().tags[0].status |= 4; + assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Flag); + let mut untagged = definitions.clone(); + untagged.remove(&id); + assert!(shaped(&node, &untagged).tags.is_empty()); } /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list @@ -2475,6 +2720,17 @@ mod tests { assert_eq!(layout.size[0], 110.0); } + #[test] + fn symbol_fonts_show_what_windows_draws() { + // OneNote's bullet library stores these (`BULLET_LIBRARY`). + assert_eq!(symbol_text("Symbol", "\u{b7}"), "\u{2022}"); + assert_eq!(symbol_text("Symbol", "\u{de}"), "\u{21d2}"); + assert_eq!(symbol_text("Symbol", "ap\u{f0}"), "\u{3b1}\u{3c0}\u{f8ff}"); + assert_eq!(symbol_text("Wingdings", "l\u{ad}"), "\u{f06c}\u{f0ad}"); + assert_eq!(symbol_text("Wingdings 2", "\u{9d}"), "\u{f09d}"); + assert_eq!(symbol_text("Calibri", "\u{b7}"), "\u{b7}"); + } + #[test] fn numerals_follow_onenote_number_sequences() { assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12"); @@ -2482,8 +2738,70 @@ mod tests { assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv"); assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C"); assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z"); - assert!(numeral(Some('\u{4}'), 27).is_err()); assert!(numeral(Some('\u{1}'), 0).is_err()); + // As OneNote 2010 renders them through COM: letters to 800, Roman numerals to 4010. + assert_eq!(numeral(Some('\u{4}'), 27).unwrap(), "aa"); + assert_eq!(numeral(Some('\u{3}'), 105).unwrap(), "AAAAA"); + assert_eq!(numeral(Some('\u{3}'), 780).unwrap(), "Z".repeat(30)); + assert_eq!(numeral(Some('\u{3}'), 781).unwrap(), "A"); + assert_eq!(numeral(Some('\u{3}'), 800).unwrap(), "T"); + assert_eq!(numeral(Some('\u{1}'), 3999).unwrap(), "MMMCMXCIX"); + assert_eq!(numeral(Some('\u{1}'), 4009).unwrap(), "MMMMIX"); + assert_eq!(numeral(Some('\u{2}'), 4000).unwrap(), "mmmm"); + assert_eq!(numeral(Some('\u{16}'), 7).unwrap(), "07"); + assert_eq!(numeral(Some('\u{16}'), 106).unwrap(), "106"); assert!(numeral(Some('\u{9}'), 1).is_err()); + // As OneNote 2010 renders them through COM, 1 to 130. + let words = |sequence, numbers: &[u32]| { + numbers + .iter() + .map(|number| numeral(Some(sequence), *number).unwrap()) + .collect::>() + }; + assert_eq!( + words( + '\u{5}', + &[1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 101, 111, 112, 121] + ), + [ + "1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd", + "101st", "111th", "112th", "121st" + ] + ); + assert_eq!( + words('\u{6}', &[1, 12, 20, 21, 99, 100, 101, 130]), + [ + "One", + "Twelve", + "Twenty", + "Twenty-one", + "Ninety-nine", + "One hundred", + "One hundred one", + "One hundred thirty" + ] + ); + assert_eq!( + words( + '\u{7}', + &[1, 2, 3, 5, 8, 9, 12, 20, 21, 40, 100, 101, 112, 130] + ), + [ + "First", + "Second", + "Third", + "Fifth", + "Eighth", + "Ninth", + "Twelfth", + "Twentieth", + "Twenty-first", + "Fortieth", + "One hundredth", + "One hundred first", + "One hundred twelfth", + "One hundred thirtieth" + ] + ); } } diff --git a/crates/canvas/tests/links_equations.rs b/crates/canvas/tests/links_equations.rs new file mode 100644 index 0000000000000000000000000000000000000000..bd1f22a71483efcb34c2176a75c92e328f6f1a5e --- /dev/null +++ b/crates/canvas/tests/links_equations.rs @@ -0,0 +1,537 @@ +//! Links and equations made as a user makes them, through the editor's typing, Link +//! dialog, Remove Link, Alt+=, Linear and Professional, save as the ops the editor recorded +//! and read back as the page it saved. `CANVAS_LINKS_EQUATIONS_EXPORT` names a new directory +//! receiving the section for a cold OneNote reopen, with the links and MathML its read must +//! show (`corpus/link-edit/editor`). + +use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; +use onestore::{ + Arena, ExGuid, RevisionIndex, Section, Store, + document::Document, + page::{ + Math, Page, PageObject, + link::{LinkTarget, internal_link}, + }, +}; +use std::path::Path; + +const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; + +fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> { + let store = Store::parse(section).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .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 caret(editor: &CanvasEditor) -> TextPosition { + editor.selection().positions[1] +} + +/// End, then Enter, returning the new paragraph. +fn line(editor: &mut CanvasEditor, engine: &mut TextEngine) -> usize { + let paragraph = caret(editor).paragraph; + let text = editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap(); + let offset = text.utf16_offset(text.text().len()).unwrap(); + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); + editor.enter(engine, false).unwrap(); + caret(editor).paragraph +} + +fn select(editor: &mut CanvasEditor, paragraph: usize, range: std::ops::Range) { + editor + .select( + [ + TextPosition { + paragraph, + offset: range.start, + }, + TextPosition { + paragraph, + offset: range.end, + }, + ] + .into(), + ) + .unwrap(); +} + +#[test] +fn links_and_equations_save_as_the_editor_shows_them() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source = std::fs::read(root.join("corpus/link-edit/internal/candidate/links.one")).unwrap(); + let section_id = Store::parse(&source).unwrap().header.file_id; + let all = pages(&source); + let (space, page) = all + .iter() + .find(|(_, page)| page.title.starts_with("Linking page")) + .cloned() + .unwrap(); + let target = all + .iter() + .map(|(_, page)| page) + .find(|page| page.title == "Link target") + .unwrap(); + let paragraph_id = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline.paragraphs[0].id), + _ => None, + }) + .unwrap(); + let mut engine = TextEngine::default(); + let engine = &mut engine; + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + let end = editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap(); + let end = end.utf16_offset(end.text().len()).unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: end, + }; 2] + .into(), + ) + .unwrap(); + let mut hrefs = vec![internal_link( + section_id, + BASE_PATH, + LinkTarget::Page { + identity: target.identity.unwrap(), + title: "Link target", + }, + )]; + // Typed URLs ended by a space and by Enter. + line(&mut editor, engine); + typed( + &mut editor, + engine, + "Visit www.example.com or (https://example.org/a?b=1). ", + ); + hrefs.extend([ + "http://www.example.com".into(), + "https://example.org/a?b=1".into(), + ]); + line(&mut editor, engine); + typed(&mut editor, engine, "Ends with http://example.net/end"); + hrefs.push("http://example.net/end".into()); + // The Link dialog on a selection, on the word at the caret and on nothing. + let at = line(&mut editor, engine); + typed(&mut editor, engine, "Read the Rust book today"); + select(&mut editor, at, 9..18); + assert_eq!(editor.link_prefill(), ("Rust book".into(), String::new())); + editor + .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/") + .unwrap(); + hrefs.push("https://doc.rust-lang.org/book/".into()); + let at = line(&mut editor, engine); + typed(&mut editor, engine, "A word here"); + select(&mut editor, at, 3..3); + editor.set_link(engine, "word", "example.net/x").unwrap(); + hrefs.push("http://example.net/x".into()); + line(&mut editor, engine); + editor + .set_link(engine, "", "https://example.com/only") + .unwrap(); + typed(&mut editor, engine, " tail"); + hrefs.push("https://example.com/only".into()); + // A link to the other page and one to this page's first paragraph, as picked or pasted. + line(&mut editor, engine); + typed(&mut editor, engine, "See "); + editor + .set_link(engine, "the target page", &hrefs[0].clone()) + .unwrap(); + hrefs.push(hrefs[0].clone()); + line(&mut editor, engine); + let paragraph_link = internal_link( + section_id, + BASE_PATH, + LinkTarget::Object { + identity: page.identity.unwrap(), + title: "Linking page", + object: paragraph_id, + }, + ); + editor + .set_link(engine, "Back to the top", ¶graph_link) + .unwrap(); + hrefs.push(paragraph_link); + // Remove Link. + let at = line(&mut editor, engine); + typed(&mut editor, engine, "Gone ftp://h.example/f z"); + select(&mut editor, at, 8..8); + assert!(editor.remove_link(engine).unwrap()); + // Equations typed after Alt+=, one shown in its linear form, one after text. + for linear in [ + "a_1+b_2", + "\\int_0^1 x dx", + "(a+b)/(c+d)", + "\\sqrt x+1", + "\\sum_(i=1)^n i", + "\\matrix(1&2@3&4)", + "e^(x+1)", + ] { + line(&mut editor, engine); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, &format!("{linear} ")); + } + assert!(editor.linear_equation(engine).unwrap()); + let at = line(&mut editor, engine); + typed(&mut editor, engine, "Area "); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "\\pi r^2 "); + // Linear, then Professional, rebuilds an equation as it was. + let built = editor + .active_outline() + .document() + .paragraphs() + .nth(at) + .unwrap() + .clone(); + select(&mut editor, at, 7..7); + assert!(editor.linear_equation(engine).unwrap()); + assert!(editor.build_equation(engine).unwrap()); + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .nth(at) + .unwrap(), + &built + ); + // Enter at an equation's start, inside its row, inside a fraction's argument, and at a + // link's start. + let paragraph = |editor: &CanvasEditor, at: usize| { + editor + .active_outline() + .document() + .paragraphs() + .nth(at) + .unwrap() + .clone() + }; + let at = line(&mut editor, engine); + typed(&mut editor, engine, "Split "); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "y^2+1 "); + select(&mut editor, at, 6..6); + editor.enter(engine, false).unwrap(); + let text = paragraph(&editor, at + 1); + let one = text.utf16_offset(text.text().find('1').unwrap()).unwrap(); + select(&mut editor, at + 1, one..one); + editor.enter(engine, false).unwrap(); + let at = line(&mut editor, engine); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "(a+b)/(c+d) "); + let text = paragraph(&editor, at); + let plus = text.utf16_offset(text.text().rfind('+').unwrap()).unwrap(); + select(&mut editor, at, plus..plus); + editor.enter(engine, false).unwrap(); + let at = line(&mut editor, engine); + typed(&mut editor, engine, "Moved "); + editor + .set_link(engine, "down", "https://example.com/moved") + .unwrap(); + hrefs.push("https://example.com/moved".into()); + let text = paragraph(&editor, at); + let label = text + .utf16_offset(text.text().find("down").unwrap()) + .unwrap(); + select(&mut editor, at, label..label); + editor.enter(engine, false).unwrap(); + + let after = editor.page().unwrap(); + let arena = Arena::default(); + let mut stored = Section::open(&arena, source.clone()).unwrap(); + let edit = onestore::op::Edit { + at: 133_000_000_000_000_000, + ops: editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }; + stored.apply("Snowbound", &edit).unwrap(); + stored.seal().unwrap(); + let saved = stored.image(); + let (_, reread) = pages(&saved) + .into_iter() + .find(|(id, _)| *id == space) + .unwrap(); + let body = |page: &Page| -> Vec { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .flat_map(|outline| outline.paragraphs.clone()) + .collect() + }; + for (stored, shown) in body(&reread).iter().zip(body(&after)) { + assert_eq!(stored, &shown); + } + assert_eq!( + Page { + title: after.title.clone(), + ..reread.clone() + }, + after + ); + + let paragraphs: Vec<_> = reread + .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()) + .collect(); + let stored_hrefs: Vec = paragraphs + .iter() + .flat_map(|text| { + text.text + .spans() + .iter() + .scan(0, |start, span| { + let run = &text.text.text()[*start..span.end]; + *start = span.end; + Some((run, span.format.clone())) + }) + .filter(|(_, format)| format.hyperlink == Some(true) && format.hidden != Some(true)) + .map(|(run, _)| run.to_owned()) + .collect::>() + }) + .collect(); + assert_eq!(stored_hrefs.len(), hrefs.len(), "{stored_hrefs:?}"); + // OneNote links URL text again when it opens a page, after its own Remove Link too + // (`corpus/link-edit/native-typed`, cold read). + let moved = hrefs.len() - 1; + hrefs.insert(moved, "ftp://h.example/f".into()); + // The MathML OneNote exports for each run of math, an equation of its own. + let mut mathml = Vec::new(); + for text in ¶graphs { + let mut start = 0; + let mut zone = Vec::new(); + for span in text.text.spans() { + let run = text.text.text()[start..span.end].to_owned(); + start = span.end; + if span.format.math == Some(true) { + zone.push((run, span.format.clone())); + } + let ends = span.format.math != Some(true) || start == text.text.text().len(); + if ends && !zone.is_empty() { + let math = onestore::page::Paragraph::from_runs(std::mem::take(&mut zone)); + mathml.push(Math::mathml(&Math::parse(&math).unwrap())); + } + } + } + assert_eq!(mathml.len(), 11); + if let Some(directory) = std::env::var_os("CANVAS_LINKS_EQUATIONS_EXPORT") { + let directory = Path::new(&directory); + std::fs::create_dir(directory).unwrap(); + std::fs::write(directory.join("links.one"), &saved).unwrap(); + std::fs::copy( + root.join("corpus/link-edit/internal/candidate/Open Notebook.onetoc2"), + directory.join("Open Notebook.onetoc2"), + ) + .unwrap(); + std::fs::write( + directory.join("expected.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "hrefs": hrefs, + "mathml": mathml, + })) + .unwrap(), + ) + .unwrap(); + } +} + +/// A paragraph's runs as (text, hidden, hyperlink, math, italic, run data), with a run +/// between objects stored either with or without run data. +type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option)>)>; + +fn runs(text: &onestore::page::Paragraph) -> Runs { + let mut start = 0; + text.spans() + .iter() + .map(|span| { + let run = text.text()[start..span.end].to_owned(); + start = span.end; + let format = &span.format; + let object = format + .math_object + .as_ref() + .map(|object| (object.kind, object.arguments)) + .filter(|(kind, _)| *kind != 0x9000_0000); + ( + run, + format.hidden == Some(true), + format.hyperlink == Some(true), + format.math == Some(true), + format.italic == Some(true), + object, + ) + }) + .collect() +} + +/// Enter at an equation's start, inside its row and inside a fraction's argument, and at a +/// link's start, stores what OneNote 2010 stored for the same keys (`tools/native_enter.py`, +/// `corpus/math-edit/native-enter`). +#[test] +fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let native = + std::fs::read(root.join("corpus/math-edit/native-enter/notebook/links.one")).unwrap(); + let (_, page) = pages(&native).remove(0); + let native: Vec = 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()) + .skip(1) + .map(|text| runs(&text.text)) + .collect(); + let mut engine = TextEngine::default(); + let engine = &mut engine; + let mut editor = CanvasEditor::new( + engine, + canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new( + String::new(), + onestore::document::Format { + font: Some("Calibri".into()), + ..Default::default() + }, + )]) + .unwrap(), + 400.0, + ) + .unwrap(); + let text = |editor: &CanvasEditor, paragraph: usize| { + editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap() + .clone() + }; + let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| { + let text = text(editor, paragraph); + let at = text.text().find(needle).unwrap(); + text.utf16_offset(at).unwrap() + }; + typed(&mut editor, engine, "Before "); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "y^2+1 "); + select(&mut editor, 0, 7..7); + editor.enter(engine, false).unwrap(); + let at = before(&editor, 1, "1"); + select(&mut editor, 1, at..at); + editor.enter(engine, false).unwrap(); + line(&mut editor, engine); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "(a+b)/(c+d) "); + let at = before(&editor, 2, "+𝑑"); + select(&mut editor, 2, at..at); + editor.enter(engine, false).unwrap(); + let paragraph = line(&mut editor, engine); + typed(&mut editor, engine, "Select me please"); + select(&mut editor, paragraph, 7..16); + editor + .set_link(engine, "me please", "https://example.com/selected") + .unwrap(); + let start = before(&editor, paragraph, "me please"); + select(&mut editor, paragraph, start..start); + editor.enter(engine, false).unwrap(); + let written: Vec = editor + .active_outline() + .document() + .paragraphs() + .map(runs) + .collect(); + assert_eq!(written, native); +} + +/// An equation among text draws in its line, which grows to hold it, and URL text shows as a +/// link without being stored as one. +#[test] +fn equations_draw_inline_and_url_text_shows_as_a_link() { + let mut engine = TextEngine::default(); + let engine = &mut engine; + let mut editor = CanvasEditor::new( + engine, + canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new( + String::new(), + Default::default(), + )]) + .unwrap(), + 400.0, + ) + .unwrap(); + typed(&mut editor, engine, "Area "); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, "(a+b)/(c+d) "); + let outline = editor.active_outline(); + let layout = outline.paragraph_layout(0).unwrap(); + assert_eq!(layout.math.len(), 1); + let spaces: Vec<_> = layout.text.spaces().collect(); + let [(0, [x, baseline])] = spaces[..] else { + panic!("{spaces:?}") + }; + let (_, line) = layout.text.lines().next().unwrap(); + assert!(x > 20.0, "the equation follows the text"); + assert!(line.height >= layout.math[0].size[1]); + assert!(baseline - line.top >= layout.math[0].baseline); + + let url = onestore::page::Paragraph::new("Gone ftp://h.example/f z".into(), Default::default()); + assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]); + assert!(url.spans()[0].format.hyperlink.is_none()); +} diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 3655c2b4dc4a290bd1f9c6fc9a8e50e2f0c12307..0b3095c5bb299036e0d327efb5ea47537653eb9d 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -22,7 +22,7 @@ use std::{collections::BTreeMap, path::Path}; #[path = "../../onestore/tests/support/sweep.rs"] mod sweep; -const SECTIONS: [&str; 15] = [ +const SECTIONS: [&str; 18] = [ "corpus/outline-edit/before/notebook/synthetic.one", "corpus/paragraph-edit/before/notebook/synthetic.one", "corpus/outline-edit/tree/before/notebook/synthetic.one", @@ -36,6 +36,9 @@ const SECTIONS: [&str; 15] = [ "corpus/ink-edit/drawing/cold/notebook/ink.one", "corpus/math-edit/native-editor/notebook/links.one", "corpus/link-edit/native-links/notebook/links.one", + "corpus/link-edit/native-typed/notebook/links.one", + "corpus/math-edit/native-editor-3/notebook/links.one", + "corpus/math-edit/native-enter/notebook/links.one", "corpus/paragraph-format/cold/notebook/synthetic.one", "corpus/canvas/baseline-anchors.one", ]; @@ -82,11 +85,17 @@ enum Kind { PlaceImage, RemoveImage, Date, + Link, + Unlink, + TypedUrl, + Equation, + Linear, + Professional, Undo, Redo, } -const KINDS: [Kind; 42] = [ +const KINDS: [Kind; 48] = [ Kind::Type, Kind::TypeEnd, Kind::Compose, @@ -127,6 +136,12 @@ const KINDS: [Kind; 42] = [ Kind::PlaceImage, Kind::RemoveImage, Kind::Date, + Kind::Link, + Kind::Unlink, + Kind::TypedUrl, + Kind::Equation, + Kind::Linear, + Kind::Professional, Kind::Undo, Kind::Redo, ]; @@ -372,6 +387,28 @@ fn perform( ) .unwrap_or(false) } + Kind::Link => { + range(editor, random) + && editor + .set_link(engine, "linked", "https://example.invalid/ops") + .is_ok() + } + Kind::Unlink => place(editor, middle) && editor.remove_link(engine).unwrap_or(false), + Kind::TypedUrl => { + place(editor, middle) + && editor.insert(engine, " www.example.invalid").is_ok() + && editor.insert(engine, " ").is_ok() + } + Kind::Equation => { + place(editor, middle) + && editor.insert_equation(engine).is_ok() + && editor.insert(engine, "x^2").is_ok() + && editor.insert(engine, " ").is_ok() + } + Kind::Linear => place(editor, middle) && editor.linear_equation(engine).unwrap_or(false), + Kind::Professional => { + place(editor, middle) && editor.build_equation(engine).unwrap_or(false) + } Kind::Undo => editor.undo(engine).unwrap_or(false), Kind::Redo => editor.redo(engine).unwrap_or(false), } diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs index daccc0ad5b3c76bb8d4d43277b2d548fe63264ed..d73a64fe8a7cbc22c07bf66bbf970eb4b080242f 100644 --- a/crates/canvas/tests/structural_roundtrip.rs +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -384,7 +384,7 @@ fn sweep( #[test] fn structural_edits_reread_as_saved() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let mut paths = vec![root.join("evidence/structural-edits/probe-section/probe.one")]; + let mut paths = vec![root.join("corpus/structural-probe/probe.one")]; if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") { paths.extend(std::env::split_paths(&extra)); } @@ -522,7 +522,7 @@ fn structural_edits_write_what_onenote_reads() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let read = |path: &str| std::fs::read(root.join(path)).unwrap(); let mut engine = TextEngine::default(); - let mut probe = read("evidence/structural-edits/probe-section/probe.one"); + let mut probe = read("corpus/structural-probe/probe.one"); for (title, keys) in [ ("c6-plain", vec![(Before("Above"), Press::Tab)]), ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]), diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index cba3c6dc681dcd45635d7bdf243721bfc135be47..ae2bdd5c279f939d973cc86e7b6a8f7e8863c2d7 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -87,6 +87,9 @@ pub enum Command { ScrollPage { up: bool }, /// Moves the caret a page and the view with it, extending the selection with Shift. MovePage { up: bool }, + /// Moves the selected paragraphs past their sibling above or below: OneNote's Alt+Shift+Up + /// and Down, Option-Command on macOS as OneNote for Mac binds them. + MoveParagraphs { up: bool }, } impl Command { @@ -285,6 +288,13 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option { })) }; match key { + Key::Named(arrow @ (NamedKey::ArrowUp | NamedKey::ArrowDown)) + if command && option && !shift && !control => + { + Some(Command::MoveParagraphs { + up: *arrow == NamedKey::ArrowUp, + }) + } Key::Named(NamedKey::ArrowLeft) => { pick(Movement::Left, Movement::WordLeft, Movement::LineStart) } @@ -341,10 +351,18 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option { /// and End reaches the document's ends; Control with Up and Down moves by paragraphs. fn pc_command(key: &Key, modifiers: Modifiers) -> Option { let Modifiers { - control, option, .. + shift, + control, + option, + .. } = modifiers; if option { - return None; + return match key { + _ if !shift || control => None, + Key::Named(NamedKey::ArrowUp) => Some(Command::MoveParagraphs { up: true }), + Key::Named(NamedKey::ArrowDown) => Some(Command::MoveParagraphs { up: false }), + _ => None, + }; } let pick = |plain, with_control| Some(Command::Move(if control { with_control } else { plain })); @@ -643,6 +661,16 @@ mod tests { None, ), (Key::Character("a".into()), Modifiers::default(), None), + ( + named(NamedKey::ArrowDown), + with(|m| (m.command, m.option) = (true, true)), + Some(Command::MoveParagraphs { up: false }), + ), + ( + named(NamedKey::ArrowUp), + with(|m| (m.shift, m.option) = (true, true)), + Some(Command::Move(Movement::ParagraphStart)), + ), ]; for (key, modifiers, command) in cases { assert_eq!( @@ -666,8 +694,15 @@ mod tests { option: true, ..Modifiers::default() }; + let alt_shift = Modifiers { shift: true, ..alt }; let named = |named| Key::Named(named); let cases = [ + ( + named(NamedKey::ArrowUp), + alt_shift, + Some(Command::MoveParagraphs { up: true }), + ), + (named(NamedKey::ArrowUp), alt, None), ( named(NamedKey::ArrowLeft), control, diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index b620eecb870eb92a38016c94b72464c4748b89ed..735457a5eab9350a59d1a475e3d8b799591929c5 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,7 +1,11 @@ mod icon; + +pub use icon::Palette; mod text; +mod translucent; pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; +pub use translucent::Translucent; use bytemuck::{Pod, Zeroable}; use image::ImageDecoder; @@ -150,10 +154,20 @@ struct CachedImage { struct Batch { vertices: Range, - image: Option, + blend: Blend, scissor: [u32; 4], } +/// How a batch meets what lies beneath it. +#[derive(Clone, Copy, PartialEq)] +enum Blend { + Over, + /// A premultiplied picture over it. + Image(u64), + /// Clearing it by the batch's coverage. + Erase, +} + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct Vertex { @@ -181,6 +195,9 @@ enum AtlasKey { sources: &'static [&'static str], size: u32, phase: [u8; 2], + /// The bits of the colour `currentColor` paints. + ink: [u32; 3], + palette: [Option<[u32; 3]>; 3], }, Path { data: String, @@ -213,9 +230,47 @@ pub struct Layer<'a> { /// The colour behind the layer: on a dark one, text in dark colours of its own is /// lifted to stay legible against what lies behind it, as OneNote's dark page does. pub backdrop: Option<[f32; 4]>, + pub motion: Option, pub primitives: &'a [Primitive<'a>], } +/// A layer part of the way through appearing: faded, and leaned back in perspective. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Motion { + pub opacity: f32, + /// Radians the layer turns about the horizontal line through `pivot`, its part below + /// the line leaning away from the viewer. + pub tilt: f32, + /// Device point the layer turns about. + pub pivot: [f32; 2], +} + +impl Motion { + fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) { + match blend { + Blend::Image(_) => vertex.color = vertex.color.map(|v| v * self.opacity), + Blend::Over | Blend::Erase => vertex.color[3] *= self.opacity, + } + if self.tilt == 0.0 { + return; + } + let [width, height] = size.map(|v| v as f32); + // Seen from twice the target's height away, so a small turn reads as depth. + let distance = 2.0 * height; + let [x, y] = [ + (vertex.position[0] + 1.0) * width / 2.0, + (1.0 - vertex.position[1]) * height / 2.0, + ]; + let below = y - self.pivot[1]; + let near = distance / (distance + below * self.tilt.sin()); + let [x, y] = [ + self.pivot[0] + (x - self.pivot[0]) * near, + self.pivot[1] + below * self.tilt.cos() * near, + ]; + vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height]; + } +} + /// A layer's transform onto the target. #[derive(Clone, Copy)] struct Space { @@ -223,6 +278,7 @@ struct Space { scale: f32, origin: [f32; 2], backdrop: Option<[f32; 4]>, + motion: Option, } impl Space { @@ -271,6 +327,9 @@ pub enum PathStyle { Stroke(f32), /// Filled and blurred this far, as a soft shadow. Shadow(f32), + /// Filled, clearing what lies beneath towards transparency by the colours' opacity, so + /// whatever the system shows behind the window shows through. + Erase, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -291,12 +350,14 @@ pub enum Primitive<'a> { ink: [f32; 4], }, /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones. - /// `tint` multiplies it; `currentColor` paints white, so it takes the tint's colour. + /// `currentColor` paints in `tint`'s colour, slots take `palette`'s, other colours keep + /// theirs, and `tint`'s opacity fades the whole icon. Icon { sources: &'static [&'static str], origin: [f32; 2], size: f32, tint: [f32; 4], + palette: Palette, }, /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once @@ -378,6 +439,7 @@ pub struct Renderer { pub queue: wgpu::Queue, pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, + erase_pipeline: wgpu::RenderPipeline, atlas: wgpu::Texture, bind_group: wgpu::BindGroup, image_sampler: wgpu::Sampler, @@ -451,8 +513,21 @@ impl Renderer { cache: None, }) }; - let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); + // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear. + let pipeline = make_pipeline(wgpu::BlendState { + color: wgpu::BlendState::ALPHA_BLENDING.color, + alpha: wgpu::BlendComponent::OVER, + }); let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let erase = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }; + let erase_pipeline = make_pipeline(wgpu::BlendState { + color: erase, + alpha: erase, + }); let atlas = device.create_texture(&wgpu::TextureDescriptor { label: Some("Glyph atlas"), size: wgpu::Extent3d { @@ -518,6 +593,7 @@ impl Renderer { queue, pipeline, image_pipeline, + erase_pipeline, atlas, bind_group, image_sampler, @@ -601,6 +677,7 @@ impl Renderer { scale: layer.scale, origin: layer.origin, backdrop: layer.backdrop, + motion: layer.motion, }; for primitive in layer.primitives { if let Primitive::Image { image, rect } = primitive @@ -647,15 +724,12 @@ impl Renderer { for batch in &self.batches { let [x, y, width, height] = batch.scissor; pass.set_scissor_rect(x, y, width, height); - pass.set_pipeline(if batch.image.is_some() { - &self.image_pipeline - } else { - &self.pipeline - }); - let binding = batch - .image - .map(|id| &self.images[&id].binding) - .unwrap_or(&self.bind_group); + let (pipeline, binding) = match batch.blend { + Blend::Over => (&self.pipeline, &self.bind_group), + Blend::Image(id) => (&self.image_pipeline, &self.images[&id].binding), + Blend::Erase => (&self.erase_pipeline, &self.bind_group), + }; + pass.set_pipeline(pipeline); pass.set_bind_group(0, binding, &[]); pass.draw(batch.vertices.clone(), 0..1); } @@ -676,6 +750,7 @@ impl Renderer { scale: layer.scale, origin: layer.origin, backdrop: layer.backdrop, + motion: layer.motion, }; let bounds = match layer.clip { Some(clip) => space.scissor(clip), @@ -699,7 +774,7 @@ impl Renderer { active_images: &HashSet, ) -> Result<(), RenderError> { let start = self.vertices.len() as u32; - let mut image_id = None; + let mut blend = Blend::Over; let mut scissor = bounds; match primitive { Primitive::Icon { @@ -707,19 +782,25 @@ impl Renderer { origin, size, tint, + palette, } => { let origin = [ space.origin[0] + origin[0] * space.scale, space.origin[1] + origin[1] * space.scale, ]; - self.icon(Space { origin, ..space }, sources, *size, *tint)?; + self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?; } Primitive::Path { data, origin, style, colors, - } => self.path(space, data, *origin, *style, *colors)?, + } => { + if *style == PathStyle::Erase { + blend = Blend::Erase; + } + self.path(space, data, *origin, *style, *colors)?; + } Primitive::Text { text, origin, @@ -779,22 +860,27 @@ impl Renderer { Primitive::Image { image, rect } => { if let Some(rect) = space.visible_rect(*rect)? { self.image(image, active_images)?; - image_id = Some(image.id()); + blend = Blend::Image(image.id()); self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; } } } let end = self.vertices.len() as u32; + if let Some(motion) = space.motion { + for vertex in &mut self.vertices[start as usize..] { + motion.apply(vertex, space.size, blend); + } + } if start != end { if let Some(last) = self.batches.last_mut() - && last.image == image_id + && last.blend == blend && last.scissor == scissor { last.vertices.end = end; } else { self.batches.push(Batch { vertices: start..end, - image: image_id, + blend, scissor, }); } @@ -1073,6 +1159,7 @@ impl Renderer { sources: &'static [&'static str], size: f32, tint: [f32; 4], + palette: &Palette, ) -> Result<(), RenderError> { let size = size * space.scale; let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); @@ -1090,10 +1177,13 @@ impl Renderer { return Err(RenderError::AtlasFull); } let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let ink = [tint[0], tint[1], tint[2]]; let key = AtlasKey::Icon { sources, size: size.to_bits(), phase, + ink: ink.map(f32::to_bits), + palette: palette.bits(), }; let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { *glyph @@ -1101,7 +1191,7 @@ impl Renderer { if self.glyphs.len() >= MAX_GLYPHS { return Err(RenderError::AtlasFull); } - let glyph = self.upload(icon::rasterize(sources, size, phase))?; + let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?; self.glyphs.insert(key, Some(glyph)); glyph }; @@ -1122,7 +1212,7 @@ impl Renderer { (glyph.x + glyph.width) as f32 / atlas, (glyph.y + glyph.height) as f32 / atlas, ], - tint, + [1.0, 1.0, 1.0, tint[3]], ) } @@ -1197,7 +1287,7 @@ impl Renderer { let [x, y] = [0, 1] .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); let (kind, width) = match style { - PathStyle::Fill => (0, 0.0), + PathStyle::Fill | PathStyle::Erase => (0, 0.0), PathStyle::Stroke(width) => (1, width), PathStyle::Shadow(width) => (2, width), }; @@ -1729,6 +1819,10 @@ mod tests { impl Target { fn new(device: &wgpu::Device) -> Self { + Self::with_format(device, wgpu::TextureFormat::Rgba8UnormSrgb) + } + + fn with_format(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { Self { texture: device.create_texture(&wgpu::TextureDescriptor { label: Some("Draw readback test"), @@ -1740,7 +1834,7 @@ mod tests { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, + format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }), @@ -1809,6 +1903,7 @@ mod tests { origin: [24.0; 2], clip: None, backdrop: None, + motion: None, primitives, }] } @@ -1895,6 +1990,7 @@ mod tests { origin: [128.125 + 20.0 * index as f32, 80.25], size: 12.0, tint: [1.0; 4], + palette: Palette::default(), }); } primitives.extend((0..9).map(|step| Primitive::Text { @@ -1998,6 +2094,7 @@ mod tests { origin: [10000.0; 2], size: 12.0, tint: [1.0; 4], + palette: Palette::default(), }]), ) .unwrap(); @@ -2210,6 +2307,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + motion: None, primitives: &primitives, }], ) @@ -2246,6 +2344,112 @@ mod tests { assert_eq!(beyond, [255; 4]); } + #[test] + #[ignore = "requires a native GPU adapter"] + fn transparent_targets_hold_premultiplied_coverage_and_erase() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let primitives = [ + Primitive::Rect { + rect: [0.0, 0.0, 10.0, 10.0], + color: [1.0, 1.0, 1.0, 0.5], + }, + Primitive::Rect { + rect: [20.0, 0.0, 40.0, 10.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Path { + data: "M0 0H10V10H0Z", + origin: [30.0, 0.0], + style: PathStyle::Erase, + colors: [[0.0, 0.0, 0.0, 1.0]; 2], + }, + ]; + renderer + .draw( + &target.texture.create_view(&Default::default()), + [512, 256], + [0.0; 4], + &[Layer { + scale: 1.0, + origin: [0.0; 2], + clip: None, + backdrop: None, + motion: None, + primitives: &primitives, + }], + ) + .unwrap(); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(5, 5)[3], 128, "half coverage is half opaque"); + assert_eq!(pixel(5, 5)[0], 188, "over premultiplied colour"); + assert_eq!(pixel(25, 5), [0, 0, 255, 255]); + assert_eq!(pixel(35, 5), [0; 4], "erased back to transparency"); + assert_eq!(pixel(50, 5), [0; 4]); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn translucent_windows_take_colour_premultiplied_in_srgb() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let mut renderer = Renderer::new(device.clone(), queue.clone(), format); + let mut translucent = Translucent::new(&device, format, wgpu::TextureFormat::Rgba8Unorm); + let primitives = [ + Primitive::Rect { + rect: [0.0, 0.0, 10.0, 10.0], + color: [1.0, 1.0, 1.0, 0.2], + }, + Primitive::Rect { + rect: [20.0, 0.0, 30.0, 10.0], + color: [0.2, 0.2, 0.2, 1.0], + }, + ]; + let frame = translucent.target(&device, [512, 256]); + renderer + .draw( + &frame, + [512, 256], + [0.0; 4], + &[Layer { + scale: 1.0, + origin: [0.0; 2], + clip: None, + backdrop: None, + motion: None, + primitives: &primitives, + }], + ) + .unwrap(); + let window = Target::with_format(&device, wgpu::TextureFormat::Rgba8Unorm); + translucent.present( + &device, + &queue, + &window.texture.create_view(&Default::default()), + ); + let capture = window.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!( + pixel(5, 5), + [51, 51, 51, 51], + "white at a fifth, premultiplied in sRGB" + ); + assert_eq!( + pixel(25, 5), + [124, 124, 124, 255], + "opaque colours encode as ever" + ); + assert_eq!(pixel(50, 5), [0; 4]); + } + #[test] #[ignore = "requires a native GPU adapter"] fn layers_transform_and_clip_independently() { @@ -2272,6 +2476,7 @@ mod tests { origin: [0.0; 2], clip: Some([100.0, 50.0, 200.5, 80.0]), backdrop: None, + motion: None, primitives: &fill, }, Layer { @@ -2279,6 +2484,7 @@ mod tests { origin: [300.0, 10.0], clip: None, backdrop: None, + motion: None, primitives: &chrome, }, ], @@ -2306,6 +2512,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + motion: None, primitives: &fill, }] ), diff --git a/crates/draw/src/render/icon.rs b/crates/draw/src/render/icon.rs index 45bc71b605b85db5a06dc37ec1d5a8e92166b635..77b71307eaa064fb42ca867fd11e1ae57cb44c58 100644 --- a/crates/draw/src/render/icon.rs +++ b/crates/draw/src/render/icon.rs @@ -1,27 +1,62 @@ use swash::{ scale::image::{Content, Image}, - zeno::{Cap, Fill, Join, Mask, Placement, Stroke, Style, Transform, Vector}, + zeno::{ + self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform, + Vector, + }, }; +/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or +/// `"badge"` are recoloured to their slot's colour; an empty slot keeps the art's colours. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Palette { + pub accent: Option<[f32; 3]>, + pub highlight: Option<[f32; 3]>, + pub badge: Option<[f32; 3]>, +} + +impl Palette { + fn slot(&self, class: &str) -> Option<[f32; 3]> { + match class { + "accent" => self.accent, + "highlight" => self.highlight, + "badge" => self.badge, + _ => None, + } + } + + /// The palette as hashable bits, for caching icons per distinct palette. + pub(crate) fn bits(&self) -> [Option<[u32; 3]>; 3] { + [self.accent, self.highlight, self.badge] + .map(|slot| slot.map(|color| color.map(f32::to_bits))) + } +} + /// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by -/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, two-stop vertical -/// gradients or `currentColor`, which paints white; strokes have round caps and joins. -pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { +/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, +/// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults +/// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no +/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round +/// caps and joins. A slot's paths keep the shading among their colours: the hue turn, +/// saturation scale and lightness shift that carry the mean of the slot's colours in a +/// source onto the palette's colour apply to each of them. +pub(crate) fn rasterize( + sources: &[&str], + size: f32, + phase: [u8; 2], + ink: [f32; 3], + palette: &Palette, +) -> Image { let side = (size + 1.0).ceil() as u32; let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; + let offset = phase.map(|quarters| f32::from(quarters) * 0.25); for source in sources { let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); - let paint = |value: &str| -> Option<[[f32; 4]; 2]> { - let hex = |value: &str| { - let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) - .unwrap() - .to_be_bytes(); - super::srgb(r, g, b) - }; + let paint = |value: &str| -> Option { match value { "none" => None, - "currentColor" => Some([[1.0; 4]; 2]), + "currentColor" => Some(Paint::Flat([ink[0], ink[1], ink[2], 1.0])), _ => Some(match value.strip_prefix("url(#") { Some(id) => { let id = id.strip_suffix(')').unwrap(); @@ -29,18 +64,38 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { .descendants() .find(|node| node.attribute("id") == Some(id)) .unwrap(); - let stops: Vec<_> = gradient - .children() - .filter(|node| node.has_tag_name("stop")) - .map(|node| hex(node.attribute("stop-color").unwrap())) - .collect(); - stops.try_into().expect("Icon gradients have two stops") + Paint::linear(gradient) } - None => [hex(value); 2], + None => Paint::Flat(hex(value)), }), } }; - for path in svg.descendants().filter(|node| node.has_tag_name("path")) { + let paths = || svg.descendants().filter(|node| node.has_tag_name("path")); + let shifts: Vec<_> = ["accent", "highlight", "badge"] + .into_iter() + .filter_map(|class| { + let target = palette.slot(class)?; + let colors: Vec<_> = paths() + .filter(|path| path.attribute("class") == Some(class)) + .flat_map(|path| { + [ + path.attribute("fill").unwrap_or("#000000"), + path.attribute("stroke").unwrap_or("none"), + ] + }) + .filter(|value| *value != "currentColor") + .filter_map(paint) + .flat_map(|paint| paint.colors()) + .collect(); + Some((class, Shift::new(&colors, target)?)) + }) + .collect(); + for path in paths() { + let data = path.attribute("d").unwrap(); + let shift = shifts + .iter() + .find(|(class, _)| path.attribute("class") == Some(*class)) + .map(|(_, shift)| shift); let fill = match path.attribute("fill-rule") { Some("evenodd") => Fill::EvenOdd, _ => Fill::NonZero, @@ -48,13 +103,19 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { let pen = path .attribute("stroke-width") .map_or(1.0, |width| width.parse().unwrap()); + let opacity = |name| { + path.attribute(name) + .map_or(1.0, |value| value.parse().unwrap()) + }; let layers = [ ( path.attribute("fill").unwrap_or("#000000"), + opacity("fill-opacity"), Style::from(fill), ), ( path.attribute("stroke").unwrap_or("none"), + opacity("stroke-opacity"), Style::from(Stroke { start_cap: Cap::Round, end_cap: Cap::Round, @@ -63,22 +124,32 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { }), ), ]; - for (value, style) in layers { - let Some([top, bottom]) = paint(value) else { + for (value, opacity, style) in layers { + let Some(mut paint) = paint(value) else { continue; }; - let (mask, _) = Mask::new(path.attribute("d").unwrap()) + if let Some(shift) = shift.filter(|_| value != "currentColor") { + paint.recolor(|color| shift.apply(color)); + } + let color = paint.shader(data); + let (mask, _) = Mask::new(data) .style(style) .transform(Some(Transform::scale(size / 16.0, size / 16.0))) - .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) + .render_offset(Vector::new(offset[0], offset[1])) .size(side, side) .render(); for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { - let alpha = f32::from(coverage) / 255.0; - let y = ((index / side as usize) as f32 / size).min(1.0); + if coverage == 0 { + continue; + } + let alpha = f32::from(coverage) / 255.0 * opacity; + // The pixel's centre in icon units. + let [x, y] = [index % side as usize, index / side as usize] + .map(|device| device as f32 + 0.5) + .map(|device| device * 16.0 / size); + let color = color([x - offset[0] * 16.0 / size, y - offset[1] * 16.0 / size]); for channel in 0..3 { - let color = top[channel] + (bottom[channel] - top[channel]) * y; - pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); + pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); } pixel[3] = alpha + pixel[3] * (1.0 - alpha); } @@ -114,6 +185,270 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { } } +fn hex(value: &str) -> [f32; 4] { + let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) + .unwrap() + .to_be_bytes(); + super::srgb(r, g, b) +} + +/// A number or a percentage of `whole`. +fn number(value: &str, whole: f32) -> f32 { + match value.strip_suffix('%') { + Some(percent) => percent.parse::().unwrap() / 100.0 * whole, + None => value.parse().unwrap(), + } +} + +enum Paint { + Flat([f32; 4]), + Linear { + /// Colour and offset of each stop. + stops: [([f32; 4], f32); 2], + /// Where offsets 0 and 1 lie, in icon units or fractions of the shape's box. + line: [[f32; 2]; 2], + bounding: bool, + }, +} + +impl Paint { + fn colors(&self) -> Vec<[f32; 4]> { + match self { + Self::Flat(color) => vec![*color], + Self::Linear { stops, .. } => stops.iter().map(|(color, _)| *color).collect(), + } + } + + fn recolor(&mut self, change: impl Fn([f32; 4]) -> [f32; 4]) { + match self { + Self::Flat(color) => *color = change(*color), + Self::Linear { stops, .. } => { + for (color, _) in stops { + *color = change(*color); + } + } + } + } + + fn linear(gradient: roxmltree::Node) -> Self { + assert!( + gradient.attribute("gradientTransform").is_none(), + "Icon gradients have no gradientTransform" + ); + let stops: Vec<_> = gradient + .children() + .filter(|node| node.has_tag_name("stop")) + .map(|node| { + ( + hex(node.attribute("stop-color").unwrap()), + node.attribute("offset") + .map_or(0.0, |offset| number(offset, 1.0)), + ) + }) + .collect(); + let bounding = gradient.attribute("gradientUnits") != Some("userSpaceOnUse"); + // Percentages are of the shape's box or of the icon. + let whole = if bounding { 1.0 } else { 16.0 }; + let at = |name, default| { + gradient + .attribute(name) + .map_or(default * whole, |value| number(value, whole)) + }; + Self::Linear { + stops: stops.try_into().expect("Icon gradients have two stops"), + line: [ + [at("x1", 0.0), at("y1", 0.0)], + [at("x2", 1.0), at("y2", 0.0)], + ], + bounding, + } + } + + /// The colour at each point of `data` in icon units. + fn shader(&self, data: &str) -> impl Fn([f32; 2]) -> [f32; 4] { + let (stops, [from, to], frame) = match *self { + Self::Flat(color) => ([(color, 0.0), (color, 1.0)], [[0.0; 2], [1.0, 0.0]], None), + Self::Linear { + stops, + line, + bounding, + } => (stops, line, bounding.then(|| bounds(data))), + }; + let direction = [to[0] - from[0], to[1] - from[1]]; + let length = direction[0] * direction[0] + direction[1] * direction[1]; + move |point| { + let [x, y] = match frame { + // A flat box has no extent to spread across on that axis. + Some([min, max]) => std::array::from_fn(|axis| { + let extent = max[axis] - min[axis]; + if extent > 0.0 { + (point[axis] - min[axis]) / extent + } else { + 0.0 + } + }), + None => point, + }; + // A gradient of no length paints its last stop. + let along = if length > 0.0 { + ((x - from[0]) * direction[0] + (y - from[1]) * direction[1]) / length + } else { + 1.0 + }; + let [(first, start), (last, end)] = stops; + let t = if end > start { + ((along - start) / (end - start)).clamp(0.0, 1.0) + } else { + f32::from(along >= start) + }; + std::array::from_fn(|channel| first[channel] + (last[channel] - first[channel]) * t) + } + } +} + +/// A turn of hue, scale of saturation and shift of lightness in sRGB's HSL. +struct Shift([f32; 3]); + +impl Shift { + /// The shift carrying the mean of linear `colors` onto linear `target`. + fn new(colors: &[[f32; 4]], target: [f32; 3]) -> Option { + let count = colors.len() as f32; + let mean: [f32; 3] = std::array::from_fn(|channel| { + colors + .iter() + .map(|color| encode(color[channel])) + .sum::() + / count + }); + let [from, to] = [mean, target.map(encode)].map(hsl); + (count > 0.0).then(|| { + Self([ + to[0] - from[0], + if from[1] > 0.0 { to[1] / from[1] } else { 0.0 }, + to[2] - from[2], + ]) + }) + } + + fn apply(&self, color: [f32; 4]) -> [f32; 4] { + let [hue, saturation, lightness] = hsl([color[0], color[1], color[2]].map(encode)); + let [turn, scale, lift] = self.0; + let [r, g, b] = rgb([ + (hue + turn).rem_euclid(360.0), + (saturation * scale).clamp(0.0, 1.0), + (lightness + lift).clamp(0.0, 1.0), + ]) + .map(decode); + [r, g, b, color[3]] + } +} + +fn encode(linear: f32) -> f32 { + if linear <= 0.003_130_8 { + linear * 12.92 + } else { + 1.055 * linear.powf(1.0 / 2.4) - 0.055 + } +} + +fn decode(encoded: f32) -> f32 { + if encoded <= 0.040_45 { + encoded / 12.92 + } else { + ((encoded + 0.055) / 1.055).powf(2.4) + } +} + +/// Hue in degrees, saturation and lightness of an encoded sRGB colour. +fn hsl([r, g, b]: [f32; 3]) -> [f32; 3] { + let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; + let lightness = (max + min) / 2.0; + let range = max - min; + if range == 0.0 { + return [0.0, 0.0, lightness]; + } + let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs()); + let sector = if max == r { + (g - b) / range + } else if max == g { + (b - r) / range + 2.0 + } else { + (r - g) / range + 4.0 + }; + [(sector * 60.0).rem_euclid(360.0), saturation, lightness] +} + +fn rgb([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + [0.0, 8.0, 4.0].map(|offset: f32| { + let k = (offset + hue / 30.0).rem_euclid(12.0); + lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) + }) +} + +/// The tight box around a path's geometry, curves included, as SVG's bounding box is. +fn bounds(data: &str) -> [[f32; 2]; 2] { + struct Sampled { + current: Point, + min: [f32; 2], + max: [f32; 2], + } + impl Sampled { + fn add(&mut self, point: Point) { + self.min = [self.min[0].min(point.x), self.min[1].min(point.y)]; + self.max = [self.max[0].max(point.x), self.max[1].max(point.y)]; + } + } + impl PathBuilder for Sampled { + fn current_point(&self) -> Point { + self.current + } + fn move_to(&mut self, to: impl Into) -> &mut Self { + self.current = to.into(); + self.add(self.current); + self + } + fn line_to(&mut self, to: impl Into) -> &mut Self { + self.move_to(to) + } + fn quad_to(&mut self, control: impl Into, to: impl Into) -> &mut Self { + let [start, control, to] = [self.current, control.into(), to.into()]; + let lift = |end: Point| end + (control - end) * (2.0 / 3.0); + self.curve_to(lift(start), lift(to), to) + } + fn curve_to( + &mut self, + first: impl Into, + second: impl Into, + to: impl Into, + ) -> &mut Self { + let [start, first, second, to] = [self.current, first.into(), second.into(), to.into()]; + for step in 1..=32 { + let t = step as f32 / 32.0; + let u = 1.0 - t; + self.add( + start * (u * u * u) + + first * (3.0 * u * u * t) + + second * (3.0 * u * t * t) + + to * (t * t * t), + ); + } + self.current = to; + self + } + fn close(&mut self) -> &mut Self { + self + } + } + let mut sampled = Sampled { + current: Point::ZERO, + min: [f32::INFINITY; 2], + max: [f32::NEG_INFINITY; 2], + }; + zeno::apply(data, Fill::NonZero, None, &mut sampled); + [sampled.min, sampled.max] +} + #[cfg(test)] mod tests { use super::*; @@ -121,23 +456,123 @@ mod tests { const BOX: &str = r##""##; const MARK: &str = r##""##; + fn gradient(attributes: &str, stops: &str) -> String { + format!( + r##"{stops}"## + ) + } + + const BLACK_WHITE: &str = + r##""##; + + /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a + /// black-to-white ramp paints at fraction `t`. + fn grey(source: &str, x: usize, y: usize) -> u8 { + rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4] + } + + fn ramp(t: f32) -> u8 { + super::super::srgb_byte(t) + } + #[test] - fn current_color_strokes_paint_white_coverage() { - const LINE: &str = r##""##; - let image = rasterize(&[LINE], 16.0, [0, 0]); + fn gradients_span_the_shape_unless_in_user_space() { + let vertical = gradient(r#"x2="0" y2="1""#, BLACK_WHITE); + // Pixel centres 4.5 and 11.5 lie 0.5 inside the shape's 8-unit box. + assert_eq!(grey(&vertical, 8, 4), ramp(0.5 / 8.0)); + assert_eq!(grey(&vertical, 8, 11), ramp(7.5 / 8.0)); + let user = gradient( + r#"gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="16""#, + BLACK_WHITE, + ); + assert_eq!(grey(&user, 8, 4), ramp(4.5 / 16.0)); + assert_eq!(grey(&user, 8, 11), ramp(11.5 / 16.0)); + } + + #[test] + fn gradients_default_to_horizontal_and_pad_past_their_stops() { + let across = gradient("", BLACK_WHITE); + assert_eq!(grey(&across, 3, 4), ramp(3.5 / 16.0)); + assert_eq!(grey(&across, 3, 11), ramp(3.5 / 16.0)); + let inner = gradient( + r#"x2="0" y2="1""#, + r##""##, + ); + assert_eq!(grey(&inner, 8, 4), 0); + assert_eq!(grey(&inner, 8, 11), 255); + assert_eq!(grey(&inner, 8, 7), ramp((3.5 / 8.0 - 0.25) / 0.5)); + } + + #[test] + fn bounds_follow_curves_not_their_control_points() { + let [min, max] = bounds("M2 8A6 6 0 0 1 14 8Q8 14 2 8Z"); + assert!((min[1] - 2.0).abs() < 0.01, "{min:?}"); + assert!((max[1] - 11.0).abs() < 0.01, "{max:?}"); + assert!((min[0] - 2.0).abs() < 0.01 && (max[0] - 14.0).abs() < 0.01); + } + + #[test] + fn slots_take_the_palette_and_keep_their_shading() { + // Two blues of one hue in the accent slot, a red outside it. + const ART: &str = r##""##; + let pixel = |palette: &Palette, y: usize| { + let image = rasterize(&[ART], 16.0, [0, 0], [0.0; 3], palette); + let at = (y * 17 + 8) * 4; + let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); + hsl([r, g, b]) + }; + let plain = Palette::default(); + let green = Palette { + accent: Some([0.0, 1.0, 0.0].map(decode)), + ..plain + }; + let [light, dark] = [2, 6].map(|y| pixel(&green, y)); + // Both turn green, the lighter above the darker, centred on the palette's colour. + for [hue, ..] in [light, dark] { + assert!((hue - 120.0).abs() < 2.0, "{hue}"); + } + assert!(light[2] > dark[2]); + assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); + assert_eq!(pixel(&green, 10), pixel(&plain, 10)); + assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]); + assert!((pixel(&plain, 2)[0] - hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); + } + + #[test] + fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() { + const LINE: &str = r##""##; + let ink = super::super::srgb(0x20, 0x60, 0xa0); + let image = rasterize( + &[LINE], + 16.0, + [0, 0], + [ink[0], ink[1], ink[2]], + &Palette::default(), + ); let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4]; - assert_eq!(pixel(8, 7), [255, 255, 255, 255]); + assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]); + assert_eq!(pixel(8, 11), [255, 0, 0, 255]); assert_eq!(pixel(8, 2)[3], 0); // Round caps reach past the path's ends. assert!(pixel(1, 7)[3] > 0); } + #[test] + fn opacity_scales_each_paint_on_its_own() { + const HALF: &str = r##""##; + let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default()); + let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3]; + assert_eq!(alpha(8, 8), 128); + // The stroke's outer half covers only what the fill leaves. + assert_eq!(alpha(1, 8), 64); + } + #[test] fn icons_keep_transparency_and_overlays_across_scales() { for size in [8.0, 16.0, 32.0, 57.5] { for phase in [[0, 0], [1, 2], [3, 3]] { - let plain = rasterize(&[BOX], size, phase); - let marked = rasterize(&[BOX, MARK], size, phase); + let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default()); + let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default()); for image in [&plain, &marked] { assert_eq!( image.data.len(), diff --git a/crates/draw/src/render/translucent.rs b/crates/draw/src/render/translucent.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9a1bee1260b20277a0e0d459d1b2adf8d5db409 --- /dev/null +++ b/crates/draw/src/render/translucent.rs @@ -0,0 +1,110 @@ +/// Carries frames to a window whose compositor blends premultiplied sRGB, as Core Animation's +/// transparent layers do. The renderer blends in linear light into a texture of its own; on +/// the way to the window each pixel is premultiplied again in sRGB, so a translucent colour +/// over whatever shows through the window looks as it would over an opaque one. +pub struct Translucent { + frame: Option, + format: wgpu::TextureFormat, + pipeline: wgpu::RenderPipeline, +} + +impl Translucent { + /// Frames the renderer draws in `format` for a window view in `window`, which stores its + /// bytes as given. + pub fn new( + device: &wgpu::Device, + format: wgpu::TextureFormat, + window: wgpu::TextureFormat, + ) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("translucent.wgsl")); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Translucent window"), + layout: None, + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(window.into())], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + Self { + frame: None, + format, + pipeline, + } + } + + /// The view to draw a frame `size` pixels large into. + pub fn target(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::TextureView { + let extent = wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }; + let frame = match &self.frame { + Some(frame) if frame.size() == extent => frame, + _ => self + .frame + .insert(device.create_texture(&wgpu::TextureDescriptor { + label: Some("Translucent frame"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + })), + }; + frame.create_view(&Default::default()) + } + + /// Copies the frame last drawn into `target` to `window`. + pub fn present(&self, device: &wgpu::Device, queue: &wgpu::Queue, window: &wgpu::TextureView) { + let Some(frame) = &self.frame else { + return; + }; + let binding = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Translucent frame"), + layout: &self.pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &frame.create_view(&Default::default()), + ), + }], + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Translucent window"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: window, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &binding, &[]); + pass.draw(0..3, 0..1); + } + queue.submit([encoder.finish()]); + } +} diff --git a/crates/draw/src/render/translucent.wgsl b/crates/draw/src/render/translucent.wgsl new file mode 100644 index 0000000000000000000000000000000000000000..5b2c4b62074c68a3848b172cd96cdc714771da80 --- /dev/null +++ b/crates/draw/src/render/translucent.wgsl @@ -0,0 +1,21 @@ +@group(0) @binding(0) var frame: texture_2d; + +@vertex +fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + let corner = vec2(f32((index << 1u) & 2u), f32(index & 2u)); + return vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} + +fn encode(linear: vec3) -> vec3 { + return select(1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, linear * 12.92, + linear <= vec3(0.0031308)); +} + +@fragment +fn fragment(@builtin(position) at: vec4) -> @location(0) vec4 { + let pixel = textureLoad(frame, vec2(at.xy), 0); + if pixel.a == 0.0 { + return vec4(0.0); + } + return vec4(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a); +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 3ab0961a1339d0c585661b2815548209e716106a..fbc91fd37a76cfd9143e1d15ed1c8d0f94fecc92 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" objc2 = "0.5.2" -objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } [target.'cfg(target_os = "linux")'.dependencies] @@ -34,3 +34,5 @@ smithay-clipboard = "0.7.3" fontique = { workspace = true, features = ["fontconfig-dlopen"] } libc = "0.2" zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] } +# The colours of winit's own window frame, which the toolbar continues. +sctk-adwaita = { version = "0.10.1", default-features = false } diff --git a/crates/snowbound/assets/icons/align-center.svg b/crates/snowbound/assets/icons/align-center.svg index 105eeb2f93cb805da32a691416323355db6c2aae..d55134b8a966bbbcf43415eddc4219aaaa72fe5f 100644 --- a/crates/snowbound/assets/icons/align-center.svg +++ b/crates/snowbound/assets/icons/align-center.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/align-left.svg b/crates/snowbound/assets/icons/align-left.svg index f02128e0867546786369e3f3819cc252bc09d30a..304d91cba55c17102c2f380dd6aec41afaa8f817 100644 --- a/crates/snowbound/assets/icons/align-left.svg +++ b/crates/snowbound/assets/icons/align-left.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/align-right.svg b/crates/snowbound/assets/icons/align-right.svg index e2861e370e4111668cb4f6f1da4eea30e6d64285..dc18ecbe2625c6f246c967d540be3e43d2fbb11f 100644 --- a/crates/snowbound/assets/icons/align-right.svg +++ b/crates/snowbound/assets/icons/align-right.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/attachment.svg b/crates/snowbound/assets/icons/attachment.svg index cb67ea008a3100f872375fc74a334a8fc1e263c7..71eb388f8cd441ff9912b4cbede6cca011005f80 100644 --- a/crates/snowbound/assets/icons/attachment.svg +++ b/crates/snowbound/assets/icons/attachment.svg @@ -1,3 +1,8 @@ - + + + + + + diff --git a/crates/snowbound/assets/icons/back.svg b/crates/snowbound/assets/icons/back.svg new file mode 100644 index 0000000000000000000000000000000000000000..c37c4e63a8c8fad333e8b355e131303ff7df4744 --- /dev/null +++ b/crates/snowbound/assets/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/bold.svg b/crates/snowbound/assets/icons/bold.svg index 321e8c2dfcfcd9449f9f278aa4582e64e6d4dc7f..e8bff68a60d2636971c3805e30d9014eca21f097 100644 --- a/crates/snowbound/assets/icons/bold.svg +++ b/crates/snowbound/assets/icons/bold.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/bullets.svg b/crates/snowbound/assets/icons/bullets.svg index 25d07438bac8c766d32572624a98a4ad15b24663..01078c8edb7e350ad3dd4961885a8e94bbe53192 100644 --- a/crates/snowbound/assets/icons/bullets.svg +++ b/crates/snowbound/assets/icons/bullets.svg @@ -1,6 +1,14 @@ - - - - + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/calendar.svg b/crates/snowbound/assets/icons/calendar.svg index 62a7a1fd9eca825a49980acd7bbf4af79892b12e..722f2c801820b408be06ce68cc409cc2eac18969 100644 --- a/crates/snowbound/assets/icons/calendar.svg +++ b/crates/snowbound/assets/icons/calendar.svg @@ -1,4 +1,16 @@ - - + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/clear-formatting.svg b/crates/snowbound/assets/icons/clear-formatting.svg index 89f884319184eeca99ed2a5d728d618e4e936410..f26d3e3bd3c1b05cb80815a9092924e44e4cd9e3 100644 --- a/crates/snowbound/assets/icons/clear-formatting.svg +++ b/crates/snowbound/assets/icons/clear-formatting.svg @@ -1,3 +1,11 @@ - + + + + + + + + + diff --git a/crates/snowbound/assets/icons/clock.svg b/crates/snowbound/assets/icons/clock.svg index b15d8232353737eb6beffd61d536875c1929e101..e38601e578ca92788cf16dcbf71546656e9ce202 100644 --- a/crates/snowbound/assets/icons/clock.svg +++ b/crates/snowbound/assets/icons/clock.svg @@ -1,3 +1,12 @@ - + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/copy.svg b/crates/snowbound/assets/icons/copy.svg new file mode 100644 index 0000000000000000000000000000000000000000..7dde3f7a9d53153f65a4e4a89eceeea93d3b7b93 --- /dev/null +++ b/crates/snowbound/assets/icons/copy.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/cut.svg b/crates/snowbound/assets/icons/cut.svg new file mode 100644 index 0000000000000000000000000000000000000000..3dcc6bff5e176757f9329e53bb14e3811d36445f --- /dev/null +++ b/crates/snowbound/assets/icons/cut.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/date-time.svg b/crates/snowbound/assets/icons/date-time.svg new file mode 100644 index 0000000000000000000000000000000000000000..a902d286e2ab921f935dc639fe5f412d52ee409f --- /dev/null +++ b/crates/snowbound/assets/icons/date-time.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/equation.svg b/crates/snowbound/assets/icons/equation.svg index a78d4234834ccdf59aaafe6d4ca6a0b37255e8b3..665904365a6a385e1a43ecf7351c7db371171b1e 100644 --- a/crates/snowbound/assets/icons/equation.svg +++ b/crates/snowbound/assets/icons/equation.svg @@ -1,3 +1,7 @@ - + + + + + diff --git a/crates/snowbound/assets/icons/find-tags.svg b/crates/snowbound/assets/icons/find-tags.svg new file mode 100644 index 0000000000000000000000000000000000000000..36adc52d5a822a51c84a49efa06bcf9c47c9145e --- /dev/null +++ b/crates/snowbound/assets/icons/find-tags.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/font-color.svg b/crates/snowbound/assets/icons/font-color.svg index 31b07897c1307af18155712412ea8e59293023c7..7e680a102ab2078748a206486e0e6d3a530a8d4a 100644 --- a/crates/snowbound/assets/icons/font-color.svg +++ b/crates/snowbound/assets/icons/font-color.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/format-painter.svg b/crates/snowbound/assets/icons/format-painter.svg new file mode 100644 index 0000000000000000000000000000000000000000..0bb1c166a2c62367f90b192f0bcf0f9092ef82f0 --- /dev/null +++ b/crates/snowbound/assets/icons/format-painter.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/forward.svg b/crates/snowbound/assets/icons/forward.svg new file mode 100644 index 0000000000000000000000000000000000000000..d55ad169eaa3488a8c809e7bd6a46036c1829b94 --- /dev/null +++ b/crates/snowbound/assets/icons/forward.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/full-page-view.svg b/crates/snowbound/assets/icons/full-page-view.svg new file mode 100644 index 0000000000000000000000000000000000000000..0043934720d99b1ceb2f72c056d5d7a920e065b0 --- /dev/null +++ b/crates/snowbound/assets/icons/full-page-view.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/highlighter.svg b/crates/snowbound/assets/icons/highlighter.svg index 2138765913a812a77f89cb7df4ecd07fc0cfbfac..1f73f70ad42d8f1b46c8e214ee183503024149f3 100644 --- a/crates/snowbound/assets/icons/highlighter.svg +++ b/crates/snowbound/assets/icons/highlighter.svg @@ -1,3 +1,14 @@ - + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/indent.svg b/crates/snowbound/assets/icons/indent.svg index 9652f7c45fd22cea621273ae2029026b6391f560..cc360d90ff623eb8f4f457ffa687ad314b19b01e 100644 --- a/crates/snowbound/assets/icons/indent.svg +++ b/crates/snowbound/assets/icons/indent.svg @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/crates/snowbound/assets/icons/insert-space.svg b/crates/snowbound/assets/icons/insert-space.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c7081c3b9842a5b913c5d3a26a211da3c784bf3 --- /dev/null +++ b/crates/snowbound/assets/icons/insert-space.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/snowbound/assets/icons/italic.svg b/crates/snowbound/assets/icons/italic.svg index e31f1c48bb272f930a8c086f51ac357193d33924..8b2277643a665634bbd68f8caa46a869671038d0 100644 --- a/crates/snowbound/assets/icons/italic.svg +++ b/crates/snowbound/assets/icons/italic.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/snowbound/assets/icons/link.svg b/crates/snowbound/assets/icons/link.svg index 4a7a6cb2bf418869fd8acbdcab78256ac2980cae..b27449e886862730932f8d9b175a5bc60e579926 100644 --- a/crates/snowbound/assets/icons/link.svg +++ b/crates/snowbound/assets/icons/link.svg @@ -1,3 +1,16 @@ - + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/notebook.svg b/crates/snowbound/assets/icons/notebook.svg index 4506a4ff25c873acc0c70162eb6b5dc347fa5b8e..08dd0bce284fc498e3128c043e2b98e3cb13601f 100644 --- a/crates/snowbound/assets/icons/notebook.svg +++ b/crates/snowbound/assets/icons/notebook.svg @@ -1,3 +1,20 @@ - + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/numbering.svg b/crates/snowbound/assets/icons/numbering.svg index a14b600f3818ddf0786c55296debbd59fbef8c62..06de39cd0c1ec285ea8026ec08793f779223cff1 100644 --- a/crates/snowbound/assets/icons/numbering.svg +++ b/crates/snowbound/assets/icons/numbering.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/crates/snowbound/assets/icons/options.svg b/crates/snowbound/assets/icons/options.svg index 17ba065db0c8afd48aac374efd9edba57aad5657..893b536c1530e188010326adcc604a2b077588be 100644 --- a/crates/snowbound/assets/icons/options.svg +++ b/crates/snowbound/assets/icons/options.svg @@ -1,5 +1,8 @@ - - - + + + + + + diff --git a/crates/snowbound/assets/icons/outdent.svg b/crates/snowbound/assets/icons/outdent.svg index edc6e9ee13bd8a4f352d3b2cfc761e7044202790..d95b6b4b5f928ae268f97acdc4d9fbb8370fd940 100644 --- a/crates/snowbound/assets/icons/outdent.svg +++ b/crates/snowbound/assets/icons/outdent.svg @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/crates/snowbound/assets/icons/page-color.svg b/crates/snowbound/assets/icons/page-color.svg new file mode 100644 index 0000000000000000000000000000000000000000..68e4de5833a4ad6acdf327f617d75d8e48970f30 --- /dev/null +++ b/crates/snowbound/assets/icons/page-color.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/page.svg b/crates/snowbound/assets/icons/page.svg index eab7c5dbcb7cc041be3e791024cf1fcc7197c9cb..2f2ff3a53296c71007d8f171ea3a0cef049da685 100644 --- a/crates/snowbound/assets/icons/page.svg +++ b/crates/snowbound/assets/icons/page.svg @@ -1,3 +1,12 @@ - + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/paste.svg b/crates/snowbound/assets/icons/paste.svg new file mode 100644 index 0000000000000000000000000000000000000000..e93f675fda9fd34d18de4633cb29a55cb637e7fe --- /dev/null +++ b/crates/snowbound/assets/icons/paste.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/picture.svg b/crates/snowbound/assets/icons/picture.svg index a771122d81f8d7bc1abfc90a799b55889c55bd92..38cd6c088e7cb6d9805dd8df598068ca77f6d27e 100644 --- a/crates/snowbound/assets/icons/picture.svg +++ b/crates/snowbound/assets/icons/picture.svg @@ -1,4 +1,15 @@ - - + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/plus.svg b/crates/snowbound/assets/icons/plus.svg index cdf878f05e7833b337ca5b40f5efb28475561c7e..6582f94c67045edfc0216efa4eae4d839ea673ab 100644 --- a/crates/snowbound/assets/icons/plus.svg +++ b/crates/snowbound/assets/icons/plus.svg @@ -1,3 +1,9 @@ - + + + + + + + diff --git a/crates/snowbound/assets/icons/record-audio.svg b/crates/snowbound/assets/icons/record-audio.svg new file mode 100644 index 0000000000000000000000000000000000000000..46c75fb0119459dfca751e09d8d0b88e6e2d74e5 --- /dev/null +++ b/crates/snowbound/assets/icons/record-audio.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/record-video.svg b/crates/snowbound/assets/icons/record-video.svg new file mode 100644 index 0000000000000000000000000000000000000000..c776f807e0792f63329c4875eb7cbe5e3eae2fee --- /dev/null +++ b/crates/snowbound/assets/icons/record-video.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/redo.svg b/crates/snowbound/assets/icons/redo.svg index 50a070148f8a00836122f8bcd58bf44c0daf5d6b..8dd4cc02843619bde01ca567c5826dfbdf2b9022 100644 --- a/crates/snowbound/assets/icons/redo.svg +++ b/crates/snowbound/assets/icons/redo.svg @@ -1,3 +1,8 @@ - + + + + + + diff --git a/crates/snowbound/assets/icons/rule-lines.svg b/crates/snowbound/assets/icons/rule-lines.svg new file mode 100644 index 0000000000000000000000000000000000000000..e4f5a27a8d4574a9658b0fc30b61dca3ab65a99e --- /dev/null +++ b/crates/snowbound/assets/icons/rule-lines.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/snowbound/assets/icons/screen-clipping.svg b/crates/snowbound/assets/icons/screen-clipping.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c4f6fa6c9eace780da2c33b08e8c7802babb26c --- /dev/null +++ b/crates/snowbound/assets/icons/screen-clipping.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/snowbound/assets/icons/search.svg b/crates/snowbound/assets/icons/search.svg index ab43123841a73ded3bfc6f178ca6e54a8319c56c..3f4ae8fbf700aedd95af1ff7d8a4dc51f9ce7dc6 100644 --- a/crates/snowbound/assets/icons/search.svg +++ b/crates/snowbound/assets/icons/search.svg @@ -1,3 +1,15 @@ - + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/section-group.svg b/crates/snowbound/assets/icons/section-group.svg index 6171b29bf0c4802e8a69e118107e3e14337f5d3f..5410564dbd50ef345acc93b8578f3070fed8b4c9 100644 --- a/crates/snowbound/assets/icons/section-group.svg +++ b/crates/snowbound/assets/icons/section-group.svg @@ -1,3 +1,14 @@ - + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/section.svg b/crates/snowbound/assets/icons/section.svg new file mode 100644 index 0000000000000000000000000000000000000000..9b71012df5b551486fca089059ec90ac4e8ad66a --- /dev/null +++ b/crates/snowbound/assets/icons/section.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/sidebar-collapse.svg b/crates/snowbound/assets/icons/sidebar-collapse.svg index bcc07d5bfacbad17b5baecc9ede9007c5343ff54..03711675704894bef3b789589216787efc785639 100644 --- a/crates/snowbound/assets/icons/sidebar-collapse.svg +++ b/crates/snowbound/assets/icons/sidebar-collapse.svg @@ -1,3 +1,13 @@ - + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/sidebar-expand.svg b/crates/snowbound/assets/icons/sidebar-expand.svg index 9feeb894e79125260832bcdfdb8d3a767f311881..31e89434c002c9a317dbd8885f6dc26fb09fd2cf 100644 --- a/crates/snowbound/assets/icons/sidebar-expand.svg +++ b/crates/snowbound/assets/icons/sidebar-expand.svg @@ -1,3 +1,13 @@ - + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/spelling.svg b/crates/snowbound/assets/icons/spelling.svg new file mode 100644 index 0000000000000000000000000000000000000000..b3a606404c7caba5e077ee00fe5ecc86ca4df3da --- /dev/null +++ b/crates/snowbound/assets/icons/spelling.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/snowbound/assets/icons/strikethrough.svg b/crates/snowbound/assets/icons/strikethrough.svg index c8af3109a10ef42d3d5898e7e70ab860650eb507..2a4aed7b55a30c24e6ad37a8c4e08f97d7354e1a 100644 --- a/crates/snowbound/assets/icons/strikethrough.svg +++ b/crates/snowbound/assets/icons/strikethrough.svg @@ -1,3 +1,4 @@ - + + diff --git a/crates/snowbound/assets/icons/subscript.svg b/crates/snowbound/assets/icons/subscript.svg index 9442665331d4dbf266ebd090414460233454e5ba..6bba2807bce379c83757e8e925de597dc0c291ad 100644 --- a/crates/snowbound/assets/icons/subscript.svg +++ b/crates/snowbound/assets/icons/subscript.svg @@ -1,4 +1,4 @@ - - + + diff --git a/crates/snowbound/assets/icons/superscript.svg b/crates/snowbound/assets/icons/superscript.svg index 22799d1a70a9a01393433cec3fd3a59f57973367..bac322a4933493c0b3d1f2ae26e988ec3828035b 100644 --- a/crates/snowbound/assets/icons/superscript.svg +++ b/crates/snowbound/assets/icons/superscript.svg @@ -1,4 +1,4 @@ - - + + diff --git a/crates/snowbound/assets/icons/symbol.svg b/crates/snowbound/assets/icons/symbol.svg new file mode 100644 index 0000000000000000000000000000000000000000..d71b258a10b91faf74b6db89b6b199234d261696 --- /dev/null +++ b/crates/snowbound/assets/icons/symbol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/snowbound/assets/icons/table.svg b/crates/snowbound/assets/icons/table.svg index 015d37c616acf4b648c1c2517bf61d7a6b903a7a..a417e3f1c9801935f5f575a12900b1139b265665 100644 --- a/crates/snowbound/assets/icons/table.svg +++ b/crates/snowbound/assets/icons/table.svg @@ -1,3 +1,11 @@ - + + + + + + + + + diff --git a/crates/snowbound/assets/icons/underline.svg b/crates/snowbound/assets/icons/underline.svg index c5f1fa2ec76574f5cb3a99f30e57d04ca5767b05..f40e0b5253264a85f6850195c7106ca150d7aefe 100644 --- a/crates/snowbound/assets/icons/underline.svg +++ b/crates/snowbound/assets/icons/underline.svg @@ -1,3 +1,4 @@ - + + diff --git a/crates/snowbound/assets/icons/undo.svg b/crates/snowbound/assets/icons/undo.svg index 0c8ceeeb3159e0cd611135f7b36ae2d2cc29fc92..f92226398aba790d4a084dc7887b1bbf7d14d2a7 100644 --- a/crates/snowbound/assets/icons/undo.svg +++ b/crates/snowbound/assets/icons/undo.svg @@ -1,3 +1,8 @@ - + + + + + + diff --git a/crates/snowbound/assets/icons/zoom-in.svg b/crates/snowbound/assets/icons/zoom-in.svg index f12d03e1f3263839f84bc066844e179c233156d6..65d1bbe6e6cf00a4c034201cf9e041c7cb3ea8d8 100644 --- a/crates/snowbound/assets/icons/zoom-in.svg +++ b/crates/snowbound/assets/icons/zoom-in.svg @@ -1,3 +1,16 @@ - + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/zoom-out.svg b/crates/snowbound/assets/icons/zoom-out.svg index 91c37f13bb72063f725451294457003f2d61a72a..0f80f2ccccb9060be42d93f8b844fcc8d5d7d72f 100644 --- a/crates/snowbound/assets/icons/zoom-out.svg +++ b/crates/snowbound/assets/icons/zoom-out.svg @@ -1,3 +1,16 @@ - + + + + + + + + + + + + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 278e733d7b1d4e30811c8fe85f406fb7ae721d93..d8eeda3ad352ea299d88601b9c19b932e8417e65 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -33,6 +33,7 @@ pub const PICTURE: &[&str] = art!("icons/picture"); pub const PLUS: &[&str] = art!("icons/plus"); pub const REDO: &[&str] = art!("icons/redo"); pub const SEARCH: &[&str] = art!("icons/search"); +pub const SECTION: &[&str] = art!("icons/section"); pub const SECTION_GROUP: &[&str] = art!("icons/section-group"); pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); @@ -45,5 +46,42 @@ pub const UNDO: &[&str] = art!("icons/undo"); pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); +// The toolbar layout's new buttons (resources/toolbar-spec.md); each loses its `expect` +// when its button lands. +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const BACK: &[&str] = art!("icons/back"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const COPY: &[&str] = art!("icons/copy"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const CUT: &[&str] = art!("icons/cut"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const DATE_TIME: &[&str] = art!("icons/date-time"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const FIND_TAGS: &[&str] = art!("icons/find-tags"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const FORWARD: &[&str] = art!("icons/forward"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const PASTE: &[&str] = art!("icons/paste"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const RULE_LINES: &[&str] = art!("icons/rule-lines"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const SPELLING: &[&str] = art!("icons/spelling"); +#[expect(dead_code, reason = "Its toolbar button has not landed")] +pub const SYMBOL: &[&str] = art!("icons/symbol"); + pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs new file mode 100644 index 0000000000000000000000000000000000000000..a44af8ef835cc27c19606a76c134295a01dc911d --- /dev/null +++ b/crates/snowbound/src/commands.rs @@ -0,0 +1,1015 @@ +//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its +//! chords on each platform, when it applies and what it does. + +use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; +use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle}; +use canvas::interaction::Request; +use draw::edit::{Key, Modifiers, NamedKey, Platform}; +use std::{error::Error, sync::Arc}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Id { + Settings, + NewNotebook, + OpenNotebook, + CloseNotebook, + NewSection, + NewSectionGroup, + NewPage, + NewSubpage, + PageVersions, + CopyPageLink, + ShowNotebook, + Undo, + Redo, + Cut, + Copy, + Paste, + SelectAll, + Find, + Search, + Back, + Forward, + ZoomIn, + ZoomOut, + ActualSize, + Sidebar, + PageList, + DarkPages, + Table, + Picture, + Attachment, + Link, + Equation, + Date, + Time, + DateTime, + Toggle(Toggle), + Highlight, + FontColor, + Bullets, + Numbering, + Align(Alignment), + Indent, + Outdent, + ClearFormatting, + Tag(NoteTag), + RemoveTags, + Help, +} + +/// What a menu or toolbar offers: a command, or one of a list's entries. +#[derive(Clone, Debug, PartialEq)] +pub enum Choice { + Command(Id), + Font(String), + Size(f32), + /// A COLORREF, or `None` for no highlight. + Highlight(Option), + /// A COLORREF, or `None` for automatic. + Color(Option), + List(Option), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Status { + pub enabled: bool, + pub checked: bool, +} + +/// A key with modifiers; `command` is the shortcut modifier, Control off macOS. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Chord { + pub key: Press, + pub shift: bool, + pub option: bool, + pub control: bool, + pub command: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Press { + /// Unshifted and lowercase, as on a US keyboard. + Char(char), + Named(NamedKey), +} + +pub struct Command { + pub id: Id, + pub title: &'static str, + pub mac: &'static [Chord], + pub pc: &'static [Chord], +} + +const fn key(key: char) -> Chord { + press(Press::Char(key)) +} + +pub(crate) const fn named(key: NamedKey) -> Chord { + press(Press::Named(key)) +} + +const fn press(key: Press) -> Chord { + Chord { + key, + shift: false, + option: false, + control: false, + command: false, + } +} + +/// `key` with the shortcut modifier. +pub(crate) const fn cmd(key: char) -> Chord { + self::key(key).command() +} + +impl Chord { + pub(crate) const fn command(mut self) -> Self { + self.command = true; + self + } + + const fn shift(mut self) -> Self { + self.shift = true; + self + } + + pub(crate) const fn option(mut self) -> Self { + self.option = true; + self + } + + pub(crate) const fn control(mut self) -> Self { + self.control = true; + self + } +} + +macro_rules! row { + ($id:expr, $title:expr, $mac:expr, $pc:expr $(,)?) => { + Command { + id: $id, + title: $title, + mac: $mac, + pc: $pc, + } + }; +} + +macro_rules! tag { + ($tag:expr, $digit:literal) => { + row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)]) + }; +} + +const NONE: &[Chord] = &[]; + +/// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's. +pub const COMMANDS: &[Command] = &[ + row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]), + row!(Id::NewNotebook, "New Notebook…", NONE, NONE), + row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]), + row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE), + row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]), + row!(Id::NewSectionGroup, "New Section Group", NONE, NONE), + row!(Id::NewPage, "New Page", &[cmd('n')], &[cmd('n')]), + row!( + Id::NewSubpage, + "New Subpage", + &[cmd('n').shift().option()], + &[cmd('n').shift().option()], + ), + row!(Id::PageVersions, "Page Versions", NONE, NONE), + row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE), + row!( + Id::ShowNotebook, + if cfg!(target_os = "macos") { + "Show in Finder" + } else { + "Open Notebook Folder" + }, + NONE, + NONE, + ), + row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]), + row!( + Id::Redo, + "Redo", + &[cmd('z').shift()], + if cfg!(windows) { + &[cmd('y'), cmd('z').shift()] + } else { + &[cmd('z').shift()] + }, + ), + row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]), + row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]), + row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]), + row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]), + row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]), + row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]), + // Option-Command-arrows move the caret; Xcode goes back with Control-Command. + row!( + Id::Back, + "Back", + &[named(NamedKey::ArrowLeft).command().control()], + &[named(NamedKey::ArrowLeft).option()], + ), + row!( + Id::Forward, + "Forward", + &[named(NamedKey::ArrowRight).command().control()], + &[named(NamedKey::ArrowRight).option()], + ), + // OneNote 2010 leaves Control with = and - to subscript and strikethrough. + row!( + Id::ZoomIn, + "Zoom In", + &[cmd('='), cmd('=').shift()], + &[cmd('=').option(), cmd('=').option().shift()], + ), + row!(Id::ZoomOut, "Zoom Out", &[cmd('-')], &[cmd('-').option()]), + row!( + Id::ActualSize, + "Actual Size", + &[cmd('0')], + &[cmd('0').option()] + ), + row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE), + row!(Id::PageList, "Page List", NONE, NONE), + row!(Id::DarkPages, "Dark Pages", NONE, NONE), + row!(Id::Table, "Table", NONE, NONE), + row!(Id::Picture, "Picture…", NONE, NONE), + row!(Id::Attachment, "Attach File…", NONE, NONE), + row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]), + row!( + Id::Equation, + "Equation", + &[key('=').control()], + &[key('=').option()], + ), + row!(Id::Date, "Date", NONE, &[key('d').option().shift()]), + row!(Id::Time, "Time", NONE, &[key('t').option().shift()]), + row!( + Id::DateTime, + "Date & Time", + NONE, + &[key('f').option().shift()] + ), + row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), + row!( + Id::Toggle(Toggle::Italic), + "Italic", + &[cmd('i')], + &[cmd('i')] + ), + row!( + Id::Toggle(Toggle::Underline), + "Underline", + &[cmd('u')], + &[cmd('u')] + ), + row!( + Id::Toggle(Toggle::Strikethrough), + "Strikethrough", + &[cmd('x').shift()], + &[cmd('-')], + ), + row!( + Id::Toggle(Toggle::Subscript), + "Subscript", + &[cmd('=').control().option()], + &[cmd('=')], + ), + row!( + Id::Toggle(Toggle::Superscript), + "Superscript", + &[cmd('=').option().shift()], + &[cmd('=').shift()], + ), + // AppKit's Hide Others takes Option-Command-H. + row!( + Id::Highlight, + "Highlight", + &[cmd('h').control()], + &[cmd('h').option()] + ), + row!(Id::FontColor, "Font Color", NONE, NONE), + row!(Id::Bullets, "Bullets", &[cmd('.')], &[cmd('.')]), + row!(Id::Numbering, "Numbering", &[cmd('/')], &[cmd('/')]), + row!( + Id::Align(Alignment::Left), + "Align Left", + &[cmd('l')], + &[cmd('l')] + ), + row!(Id::Align(Alignment::Center), "Center", NONE, NONE), + row!( + Id::Align(Alignment::Right), + "Align Right", + &[cmd('r')], + &[cmd('r')] + ), + row!( + Id::Indent, + "Increase Indent", + &[cmd(']')], + &[named(NamedKey::ArrowRight).option().shift()], + ), + row!( + Id::Outdent, + "Decrease Indent", + &[cmd('[')], + &[named(NamedKey::ArrowLeft).option().shift()], + ), + row!( + Id::ClearFormatting, + "Clear Formatting", + &[cmd('n').shift()], + &[cmd('n').shift()] + ), + tag!(NoteTag::ToDo, '1'), + tag!(NoteTag::Important, '2'), + tag!(NoteTag::Question, '3'), + tag!(NoteTag::RememberForLater, '4'), + tag!(NoteTag::Definition, '5'), + tag!(NoteTag::Highlight, '6'), + tag!(NoteTag::Contact, '7'), + tag!(NoteTag::Address, '8'), + tag!(NoteTag::PhoneNumber, '9'), + row!( + Id::RemoveTags, + "Remove Tag", + &[cmd('0').control()], + &[cmd('0')] + ), + row!(Id::Help, "Snowbound Help", NONE, NONE), +]; + +/// Where Snowbound Help leads. +const HELP: &str = "https://shale.paperclover.net/snowbound"; + +pub fn command(id: Id) -> &'static Command { + COMMANDS + .iter() + .find(|command| command.id == id) + .expect("Every command has a row") +} + +impl Command { + pub fn chords(&self, platform: Platform) -> &'static [Chord] { + match platform { + Platform::MacOs => self.mac, + Platform::Gtk | Platform::Windows => self.pc, + } + } +} + +/// Every choice the menu bar offers, in the order its `index` counts them. +pub fn choices() -> impl Iterator { + COMMANDS + .iter() + .map(|command| Choice::Command(command.id)) + .chain(FONTS.map(|name| Choice::Font(name.to_owned()))) + .chain(SIZES.into_iter().map(Choice::Size)) + .chain(std::iter::once(Choice::Highlight(None))) + .chain( + HIGHLIGHTS + .iter() + .map(|(color, _)| Choice::Highlight(Some(*color))), + ) +} + +/// How a menu names `choice`. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn title(choice: Choice) -> String { + match choice { + Choice::Command(id) => command(id).title.to_owned(), + Choice::Font(name) => name, + Choice::Size(size) => format!("{size}"), + Choice::Highlight(None) => "No Color".to_owned(), + Choice::Highlight(Some(color)) => HIGHLIGHTS + .iter() + .find(|(listed, _)| *listed == color) + .map_or_else(String::new, |(_, name)| (*name).to_owned()), + Choice::Color(_) | Choice::List(_) => unreachable!("Only the toolbar offers these"), + } +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn index(choice: &Choice) -> usize { + choices() + .position(|listed| listed == *choice) + .expect("Menus offer listed choices") +} + +/// The command `key` with `modifiers` runs here. +pub fn find(key: &Key, modifiers: Modifiers) -> Option { + let pressed = pressed(key, modifiers, Platform::CURRENT)?; + COMMANDS + .iter() + .find(|command| command.chords(Platform::CURRENT).contains(&pressed)) + .map(|command| command.id) +} + +fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option { + let (key, shifted) = match key { + Key::Named(named) => (Press::Named(*named), false), + Key::Character(text) => { + let mut chars = text.chars(); + let character = chars.next().filter(|_| chars.next().is_none())?; + match SHIFTED.iter().find(|(shifted, _)| *shifted == character) { + Some((_, base)) => (Press::Char(*base), true), + None => (Press::Char(character.to_ascii_lowercase()), false), + } + } + }; + Some(Chord { + key, + shift: modifiers.shift || shifted, + option: modifiers.option, + // Off macOS Control is the shortcut modifier. + control: modifiers.control && platform == Platform::MacOs, + command: modifiers.command, + }) +} + +/// The US keyboard's shifted symbols and the keys they share. +const SHIFTED: [(char, char); 21] = [ + ('+', '='), + ('_', '-'), + ('{', '['), + ('}', ']'), + ('<', ','), + ('>', '.'), + ('?', '/'), + (':', ';'), + ('"', '\''), + ('|', '\\'), + ('~', '`'), + (')', '0'), + ('!', '1'), + ('@', '2'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('^', '6'), + ('&', '7'), + ('*', '8'), + ('(', '9'), +]; + +impl Chord { + /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key. + pub fn equivalent(self) -> (String, bool) { + match self.key { + Press::Char(base) if self.shift && !base.is_ascii_alphabetic() => { + let shifted = SHIFTED.iter().find(|(_, key)| *key == base); + shifted.map_or((base.to_string(), true), |(symbol, _)| { + (symbol.to_string(), false) + }) + } + Press::Char(base) => (base.to_string(), self.shift), + Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift), + Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift), + Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift), + Press::Named(named) => unreachable!("No command takes {named:?}"), + } + } + + /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. + pub fn label(self, platform: Platform) -> String { + if platform == Platform::MacOs { + let (key, shift) = self.equivalent(); + let key = match self.key { + Press::Named(NamedKey::Space) => "Space".to_owned(), + Press::Named(NamedKey::ArrowLeft) => "←".to_owned(), + Press::Named(NamedKey::ArrowRight) => "→".to_owned(), + _ => key.to_uppercase(), + }; + [ + (self.control, "⌃"), + (self.option, "⌥"), + (shift, "⇧"), + (self.command, "⌘"), + ] + .iter() + .filter(|(held, _)| *held) + .map(|(_, symbol)| *symbol) + .chain([key.as_str()]) + .collect() + } else { + let key = match self.key { + Press::Char(key) => key.to_uppercase().to_string(), + Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(), + Press::Named(NamedKey::ArrowRight) => "Right".to_owned(), + Press::Named(named) => format!("{named:?}"), + }; + [ + (self.command, "Ctrl+"), + (self.option, "Alt+"), + (self.shift, "Shift+"), + ] + .iter() + .filter(|(held, _)| *held) + .map(|(_, modifier)| *modifier) + .chain([key.as_str()]) + .collect() + } + } +} + +/// The chord a menu or hint shows for `id` here, empty where it has none. +pub fn shortcut(id: Id) -> String { + command(id) + .chords(Platform::CURRENT) + .first() + .map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) +} + +impl State { + pub(crate) fn format_state(&self) -> FormatState { + self.view.editor.format_state().unwrap_or_default() + } + + /// Queues `choice` to run after this frame's input, as a key or a click on it does. + pub(crate) fn choose(&mut self, choice: Choice) { + self.commands.push(Work::Choose(choice)); + self.window.request_redraw(); + } + + /// Every choice's status, in `choices` order, for the menu bar. + pub(crate) fn statuses(&self) -> Vec { + let format = self.format_state(); + choices() + .map(|choice| self.status(&choice, &format)) + .collect() + } + + pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status { + let session = self.session.as_ref(); + let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); + let modal = self.options.is_some() || self.link.is_some(); + let page = (session.is_some() || self.temporary) && !modal; + let writable = page && !session.is_some_and(|session| session.read_only()); + let text = writable && self.view.accepts_text(); + // Edit commands act on a focused field instead of the page. + let field = self + .ui + .focused() + .is_some_and(|focus| focus != crate::page()); + let [anchor, focus] = self.view.editor.selection().positions; + let selected = page && !field && self.view.accepts_text() && anchor != focus; + let enabled = |enabled| Status { + enabled, + checked: false, + }; + let checked = |checked| Status { + enabled: text, + checked, + }; + let id = match choice { + Choice::Command(id) => *id, + Choice::Font(name) => return checked(format.font.as_ref() == Some(name)), + Choice::Size(size) => return checked(format.font_size == Some(*size)), + Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text), + }; + match id { + Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal), + Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()), + Id::NewSection | Id::NewSectionGroup => enabled( + !modal + && self + .notebook() + .is_some_and(|library| library.catalog().is_some()), + ), + Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => { + enabled(!modal && session.is_some()) + } + Id::PageVersions => { + session + .filter(|_| !modal) + .map_or_else(Status::default, |session| Status { + enabled: !session.page_versions(session.space).is_empty(), + checked: session.shown_history == Some(session.space), + }) + } + Id::Undo => enabled(writable && !field && self.view.editor.can_undo()), + Id::Redo => enabled(writable && !field && self.view.editor.can_redo()), + Id::Cut => enabled(writable && selected), + Id::Copy => enabled(selected), + Id::Paste => enabled(text && !field), + Id::SelectAll => enabled(field || page), + Id::Back => enabled(!modal && self.trail.open()[0]), + Id::Forward => enabled(!modal && self.trail.open()[1]), + Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page), + Id::Sidebar => Status { + enabled: !welcome && !modal, + checked: self.sidebar, + }, + Id::PageList => Status { + enabled: !modal && session.is_some(), + checked: self.pages_open, + }, + Id::DarkPages => Status { + enabled: !modal, + checked: !self.light_pages, + }, + Id::Table | Id::Picture | Id::Attachment => enabled(false), + Id::Link + | Id::Equation + | Id::Date + | Id::Time + | Id::DateTime + | Id::Highlight + | Id::FontColor + | Id::Indent + | Id::Outdent + | Id::ClearFormatting + | Id::RemoveTags => enabled(text), + Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)), + Id::Bullets => checked(format.bullets), + Id::Numbering => checked(format.numbering), + Id::Align(alignment) => checked(format.alignment == Some(alignment)), + Id::Tag(tag) => checked(format.tags.contains(&tag)), + } + } + + /// Applies `formatting`, remembering its font, colour or list style as the toolbar's + /// last pick. + fn format(&mut self, formatting: Formatting) -> Result<(), Box> { + let kept = self.toolbar.clone(); + let pens = &mut self.toolbar; + match &formatting { + Formatting::Font(name) => crate::remember(&mut self.recent_fonts, name.clone()), + Formatting::Highlight(color) => pens.highlight = *color, + Formatting::Color(color) => pens.font_color = *color, + Formatting::List(Some(ListStyle::Bullet(place))) => { + crate::remember(&mut pens.bullets, *place); + } + Formatting::List(Some(ListStyle::Number(place))) => { + crate::remember(&mut pens.numbering, *place); + } + _ => {} + } + if matches!(formatting, Formatting::Font(_)) || self.toolbar != kept { + self.save_settings(); + } + let response = self.view.format(formatting)?; + self.respond(response); + Ok(()) + } + + /// The notebook the open section, or the notebook showing none, belongs to. + fn notebook(&self) -> Option<&Arc> { + self.session + .as_ref() + .map(|session| &session.library) + .or(self.sectionless.as_ref()) + } + + /// Runs `choice` where it applies now. + pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box> { + if !self.status(&choice, &self.format_state()).enabled { + return Ok(()); + } + let format = Self::format; + let id = match choice { + Choice::Command(id) => id, + Choice::Font(name) => return format(self, Formatting::Font(name)), + Choice::Size(size) => return format(self, Formatting::FontSize(size)), + Choice::Highlight(color) => return format(self, Formatting::Highlight(color)), + Choice::Color(color) => return format(self, Formatting::Color(color)), + Choice::List(style) => return format(self, Formatting::List(style)), + }; + let field = self.ui.focused().filter(|focus| *focus != page()); + let response = match id { + Id::Settings => { + self.open_options(); + return Ok(()); + } + Id::NewNotebook => Work::NewNotebook, + Id::OpenNotebook => Work::OpenNotebook, + Id::CloseNotebook => { + Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?)) + } + Id::NewSection | Id::NewSectionGroup => { + let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); + let folder = self.session.as_ref().map_or_else(String::new, |session| { + crate::menus::folder(&session.tabs[session.tab].path) + }); + Work::Structure( + library, + if id == Id::NewSection { + crate::manage::Structure::NewSection { folder } + } else { + crate::manage::Structure::NewGroup { folder } + }, + ) + } + Id::NewPage => Work::NewPage { under: None }, + Id::NewSubpage => Work::NewPage { + under: self.session.as_ref().map(|session| session.space), + }, + Id::PageVersions => { + let session = self.session.as_ref().ok_or("No notebook is open")?; + Work::History { + page: session.space, + show: session.shown_history != Some(session.space), + } + } + Id::CopyPageLink => { + let space = self.session.as_ref().ok_or("No notebook is open")?.space; + let link = self.page_link(space, None)?; + self.clipboard.set_text(link)?; + return Ok(()); + } + Id::ShowNotebook => { + platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); + return Ok(()); + } + Id::Undo | Id::Redo => { + let response = self.view.undo(id == Id::Redo)?; + self.respond(response); + return Ok(()); + } + Id::Cut | Id::Copy => { + let response = self.view.copy(id == Id::Cut)?; + self.respond(response); + return Ok(()); + } + Id::Paste => Work::Page(Request::Paste), + Id::SelectAll => { + match field { + Some(field) => self.ui.focus_all(field), + None => { + let response = self.view.widen_selection()?; + self.respond(response); + } + } + return Ok(()); + } + Id::Find => { + return self.start_find(field == Some(search::field())); + } + Id::Search => { + self.start_search(); + return Ok(()); + } + Id::Back | Id::Forward => { + self.travel(id == Id::Forward); + return Ok(()); + } + Id::ZoomIn | Id::ZoomOut | Id::ActualSize => { + let zoom = match id { + Id::ZoomIn => self.view.zoom() * 1.1, + Id::ZoomOut => self.view.zoom() / 1.1, + _ => 1.0, + }; + let response = self.view.set_zoom(zoom)?; + self.respond(response); + return Ok(()); + } + Id::Sidebar => { + self.sidebar = !self.sidebar; + return Ok(()); + } + Id::PageList => { + self.pages_open = !self.pages_open; + return Ok(()); + } + Id::DarkPages => { + self.light_pages = !self.light_pages; + self.follow_color_scheme(); + self.save_settings(); + return Ok(()); + } + Id::Table | Id::Picture | Id::Attachment => return Ok(()), + Id::Link => { + self.open_link_dialog(); + return Ok(()); + } + Id::Equation => { + let response = self.view.insert_equation()?; + self.respond(response); + return Ok(()); + } + // OneNote inserts the system's short date or time and a space. + Id::Date | Id::Time | Id::DateTime => { + let now = crate::filetime(); + let date = platform::short_date(now); + let [_, time] = platform::date_text(now); + let text = match id { + Id::Date => date, + Id::Time => time, + _ => format!("{date} {time}"), + }; + let response = self.view.insert_text(format!("{text} "))?; + self.respond(response); + return Ok(()); + } + Id::Help => { + platform::reveal(HELP); + return Ok(()); + } + Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)), + // The highlighter and font colour apply their last pick. + Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)), + Id::FontColor => return format(self, Formatting::Color(self.toolbar.font_color)), + Id::Bullets => return format(self, Formatting::Bullets), + Id::Numbering => return format(self, Formatting::Numbering), + Id::Align(alignment) => return format(self, Formatting::Align(alignment)), + Id::Indent => return format(self, Formatting::Indent), + Id::Outdent => return format(self, Formatting::Outdent), + Id::ClearFormatting => return format(self, Formatting::Clear), + Id::Tag(tag) => return format(self, Formatting::Tag(tag)), + Id::RemoveTags => return format(self, Formatting::RemoveTags), + }; + self.commands.push(response); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows]; + + #[test] + fn each_chord_runs_one_command_on_each_platform() { + for platform in PLATFORMS { + let chords: Vec<_> = COMMANDS + .iter() + .flat_map(|command| { + command + .chords(platform) + .iter() + .map(move |chord| (chord, command.title)) + }) + .collect(); + for (at, (chord, title)) in chords.iter().enumerate() { + if let Some((_, other)) = + chords[at + 1..].iter().find(|(listed, _)| listed == chord) + { + panic!( + "{platform:?}: {} runs both {title} and {other}", + chord.label(platform) + ); + } + } + } + } + + #[test] + fn each_command_and_choice_is_listed_once() { + for (at, command) in COMMANDS.iter().enumerate() { + assert!( + COMMANDS[at + 1..] + .iter() + .all(|other| other.id != command.id), + "{:?}", + command.id + ); + } + let choices: Vec<_> = choices().collect(); + for (at, choice) in choices.iter().enumerate() { + assert_eq!(index(choice), at, "{choice:?}"); + } + } + + #[test] + fn keys_find_their_command_as_each_platform_types_them() { + let find = |platform, key: &str, shift, option, control, command| { + let modifiers = Modifiers { + shift, + option, + control, + command, + }; + let pressed = pressed(&Key::Character(key.into()), modifiers, platform)?; + COMMANDS + .iter() + .find(|listed| listed.chords(platform).contains(&pressed)) + .map(|listed| listed.id) + }; + let mac = Platform::MacOs; + assert_eq!( + find(mac, "b", false, false, false, true), + Some(Id::Toggle(Toggle::Bold)) + ); + assert_eq!( + find(mac, "N", true, false, false, true), + Some(Id::ClearFormatting) + ); + assert_eq!(find(mac, "+", true, false, false, true), Some(Id::ZoomIn)); + assert_eq!(find(mac, "=", false, false, false, true), Some(Id::ZoomIn)); + assert_eq!( + find(mac, "+", true, true, false, true), + Some(Id::Toggle(Toggle::Superscript)) + ); + assert_eq!( + find(mac, "=", false, false, true, false), + Some(Id::Equation) + ); + assert_eq!( + find(mac, "0", false, false, true, true), + Some(Id::RemoveTags) + ); + assert_eq!( + find(mac, "7", false, false, false, true), + Some(Id::Tag(NoteTag::Contact)) + ); + assert_eq!(find(mac, "q", false, false, false, true), None); + // The highlighter's chord runs the command that applies its last pick. + assert_eq!( + find(mac, "h", false, false, true, true), + Some(Id::Highlight) + ); + // Control comes with the shortcut modifier off macOS. + let gtk = Platform::Gtk; + assert_eq!( + find(gtk, "b", false, false, true, true), + Some(Id::Toggle(Toggle::Bold)) + ); + assert_eq!( + find(gtk, "+", true, false, true, true), + Some(Id::Toggle(Toggle::Superscript)) + ); + assert_eq!( + find(gtk, "=", false, false, true, true), + Some(Id::Toggle(Toggle::Subscript)) + ); + assert_eq!(find(gtk, "+", true, true, true, true), Some(Id::ZoomIn)); + assert_eq!(find(gtk, "D", true, true, false, false), Some(Id::Date)); + assert_eq!(find(gtk, "h", false, true, true, true), Some(Id::Highlight)); + assert_eq!( + find(gtk, "y", false, false, true, true), + cfg!(windows).then_some(Id::Redo) + ); + let indent = pressed( + &Key::Named(NamedKey::ArrowRight), + Modifiers { + shift: true, + option: true, + ..Modifiers::default() + }, + gtk, + ); + assert!( + command(Id::Indent) + .pc + .iter() + .any(|chord| Some(*chord) == indent) + ); + } + + #[test] + fn chords_read_as_each_platform_labels_them() { + let label = |id, platform| command(id).chords(platform)[0].label(platform); + assert_eq!( + label(Id::Toggle(Toggle::Superscript), Platform::MacOs), + "⌥⌘+" + ); + assert_eq!( + label(Id::Toggle(Toggle::Subscript), Platform::MacOs), + "⌃⌥⌘=" + ); + assert_eq!(label(Id::ClearFormatting, Platform::MacOs), "⇧⌘N"); + assert_eq!( + label(Id::Toggle(Toggle::Superscript), Platform::Gtk), + "Ctrl+Shift+=" + ); + assert_eq!(label(Id::Indent, Platform::Gtk), "Alt+Shift+Right"); + assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D"); + } + + /// Each toolbar button chooses a command or a list's entry, so it runs, enables and + /// checks as the menu bar and keyboard do. + #[test] + fn toolbar_buttons_run_commands() { + let source = include_str!("main.rs"); + let start = source + .find(" fn toolbar(") + .expect("The toolbar is built in main.rs"); + let body = &source[start..]; + let body = &body[..body[1..] + .find("\n fn ") + .map_or(body.len(), |end| end + 1)]; + assert!(body.contains("self.choose(")); + for effect in [ + "self.respond(", + "self.changed", + "self.view.format(", + "self.view.set_zoom(", + "self.view.insert", + "self.open_link_dialog(", + ] { + assert!( + !body.contains(effect), + "A toolbar button acts through `{effect}`; have it choose a command instead" + ); + } + } +} diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 33bb5f243abc3fa99093526dae4fe1a9ddb65430..08074f5ff0a7cfde2ff8965d8b7171aba2f1fefa 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -1,6 +1,7 @@ -//! The conflict page views drawn offscreen, from the conflict-page corpus row's candidate: -//! the page with its information bar, its versions listed beneath it, and a version open. -//! `SNOWBOUND_CONFLICT_RENDER` names a directory for the PNGs. +//! The conflict page and page version views drawn offscreen, from the conflict-page and +//! page-versions corpus rows: the page with its information bar, its conflict pages or +//! versions listed beneath it, and one open. `SNOWBOUND_CONFLICT_RENDER` names a directory +//! for the PNGs. use super::*; @@ -58,7 +59,16 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { ..Spec::default() }, ); - let rows = page_rows(ui, &theme, §ion, session, &HashSet::new()); + let rows = page_rows( + ui, + &theme, + §ion, + session, + &HashSet::new(), + 0.0, + None, + None, + ); commands.extend(rows.clicked.map(Command::OpenPage)); ui.close(); ui.close(); @@ -67,7 +77,13 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { } /// The view's pixels, RGBA: the interface with `page` drawn in its box. -fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec { +fn paint( + ui: &Ui, + page: Page, + renderer: &mut draw::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, +) -> Vec { let size = SIZE.map(|side| (side * SCALE) as u32); let corner = ui.rect(super::page()).unwrap(); let box_size = [ @@ -108,13 +124,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi let layers: Vec<_> = interface .iter() .map(|layer| match layer { - ui::Layer::Primitives { clip, primitives } => draw::Layer { - scale: SCALE, - origin: [0.0; 2], - clip: clip.map(|clip| clip.map(|value| value * SCALE)), - backdrop: None, - primitives, - }, + ui::Layer::Primitives(primitives) => primitives.layer(SCALE), ui::Layer::Custom { rect, .. } => draw::Layer { scale: viewport.scale, origin: [ @@ -123,6 +133,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi ], clip: Some(rect.map(|value| value * SCALE)), backdrop: Some(ui.theme.paper), + motion: None, primitives: &primitives, }, }) @@ -142,7 +153,12 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi view_formats: &[], }); renderer - .draw(&texture.create_view(&Default::default()), size, ui.theme.base, &layers) + .draw( + &texture.create_view(&Default::default()), + size, + ui.theme.base, + &layers, + ) .unwrap(); let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); let buffer = device.create_buffer(&wgpu::BufferDescriptor { @@ -180,6 +196,33 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi .collect() } +/// A renderer drawing into `output`'s PNGs. +fn gpu(output: &Path) -> (draw::Renderer, wgpu::Device, wgpu::Queue) { + std::fs::create_dir_all(output).unwrap(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let renderer = draw::Renderer::new( + device.clone(), + queue.clone(), + wgpu::TextureFormat::Rgba8UnormSrgb, + ); + (renderer, device, queue) +} + +/// Writes RGBA `pixels` of the view as a PNG. +fn save(path: &Path, pixels: &[u8]) { + let size = SIZE.map(|side| (side * SCALE) as u32); + let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(pixels) + .unwrap(); +} + /// The page shown, a page's versions listed while shown, and the bar's commands: showing /// opens the newest version, hiding returns to the page. #[test] @@ -189,7 +232,11 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { let _ = std::fs::remove_dir_all(&directory); std::fs::create_dir_all(directory.join("cache")).unwrap(); let file = directory.join("synthetic.one"); - std::fs::copy(root.join("corpus/conflict-page/candidate/synthetic.one"), &file).unwrap(); + std::fs::copy( + root.join("corpus/conflict-page/candidate/synthetic.one"), + &file, + ) + .unwrap(); let library = Arc::new(crate::Library::section(&file, &directory.join("cache"))); let section = library.open(&library.location, || {}).unwrap(); let path = library.location.clone(); @@ -197,17 +244,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { let page = session.space; let version = session.versions(page)[0].space; let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from); - let gpu = output.as_ref().map(|output| { - std::fs::create_dir_all(output).unwrap(); - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); - let (device, queue) = - pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let renderer = - draw::Renderer::new(device.clone(), queue.clone(), wgpu::TextureFormat::Rgba8UnormSrgb); - (renderer, device, queue) - }); - let mut gpu = gpu; + let mut gpu = output.as_deref().map(gpu); for (name, space, shown, menu, appearance) in [ ("page", page, None, false, Theme::light()), ("versions", page, Some(page), false, Theme::light()), @@ -215,7 +252,13 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { ("version-menu", version, Some(page), true, Theme::light()), ("page-dark", page, None, false, Theme::dark()), ("version-dark", version, Some(page), false, Theme::dark()), - ("version-menu-dark", version, Some(page), true, Theme::dark()), + ( + "version-menu-dark", + version, + Some(page), + true, + Theme::dark(), + ), ] { session.space = space; session.shown = shown; @@ -229,7 +272,10 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { match (name, bar) { ("page" | "page-dark", Some(Bar::Page { shown: false, .. })) | ("versions", Some(Bar::Page { shown: true, .. })) - | ("version" | "version-menu" | "version-dark" | "version-menu-dark", Some(Bar::Version { .. })) => {} + | ( + "version" | "version-menu" | "version-dark" | "version-menu-dark", + Some(Bar::Version { .. }), + ) => {} other => panic!("{other:?}", other = other.0), } let row = Id::ROOT.child("frame").child("panel").child(version); @@ -238,13 +284,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { let shown = session.reader(space)().unwrap(); let pixels = paint(&ui, shown, renderer, device, queue); - let size = SIZE.map(|side| (side * SCALE) as u32); - let path = output.join(format!("conflict-{name}.png")); - let mut encoder = - png::Encoder::new(std::fs::File::create(&path).unwrap(), size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + save(&output.join(format!("conflict-{name}.png")), &pixels); } } // A version reads with its conflicting paragraphs banded as OneNote shows them. @@ -252,13 +292,136 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { let marked = session.versions(page)[0].objects.clone(); assert!(!marked.is_empty()); let highlighted = kept.objects.iter().any(|object| match object { - onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| { - paragraph.text().is_some_and(|text| marked.contains(&text.id)) - && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) - }), + onestore::page::PageObject::Outline(outline) => { + outline.paragraphs.iter().any(|paragraph| { + paragraph + .text() + .is_some_and(|text| marked.contains(&text.id)) + && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) + }) + } _ => false, }); assert!(highlighted); session.section.close().unwrap(); std::fs::remove_dir_all(&directory).unwrap(); } + +/// A page's versions listed under it while shown, each read-only under OneNote's bar with +/// what it changed banded, from OneNote's own (`corpus/page-versions/native/step-04`). +#[test] +fn page_versions_list_open_read_only_and_render_offscreen() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let directory = std::env::temp_dir().join(format!("snowbound-history-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(directory.join("cache")).unwrap(); + let file = directory.join("History.one"); + std::fs::copy( + root.join("corpus/page-versions/native/step-04/notebook/History.one"), + &file, + ) + .unwrap(); + let library = Arc::new(crate::Library::section(&file, &directory.join("cache"))); + let section = library.open(&library.location, || {}).unwrap(); + let path = library.location.clone(); + let (mut session, _) = read_session(section, library, path, None).unwrap(); + let page = session.space; + let versions: Vec<_> = session + .page_versions(page) + .iter() + .map(|version| version.context) + .collect(); + assert_eq!(versions.len(), 2); + assert_eq!( + crate::history::label(&session.page_versions(page)[1]) + .rsplit_once(' ') + .map(|(_, who)| who), + Some("virtual") + ); + let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from); + let mut gpu = output.as_deref().map(gpu); + for (name, shown, version, menu, appearance) in [ + ("history-page", None, None, false, Theme::light()), + ("history-shown", Some(page), None, false, Theme::light()), + ( + "history-version", + Some(page), + Some(versions[0]), + false, + Theme::light(), + ), + ( + "history-menu", + Some(page), + Some(versions[0]), + true, + Theme::light(), + ), + ( + "history-version-dark", + Some(page), + Some(versions[0]), + false, + Theme::dark(), + ), + ( + "history-menu-dark", + Some(page), + Some(versions[0]), + true, + Theme::dark(), + ), + ] { + session.shown_history = shown; + session.version = version; + assert_eq!(session.read_only(), version.is_some(), "{name}"); + match (session.bar(), version) { + ( + Some(Bar::History { + version: open, + grouped: false, + .. + }), + Some(version), + ) => { + assert_eq!(open, version) + } + (None, None) => {} + _ => panic!("{name}"), + } + let mut ui = Ui::new(appearance, std::time::Duration::from_millis(500)); + for _ in 0..8 { + layout(&mut ui, &session, menu); + std::thread::sleep(std::time::Duration::from_millis(30)); + } + for context in &versions { + let row = Id::ROOT.child("frame").child("panel").child(context); + assert_eq!(ui.rect(row).is_some(), shown.is_some(), "{name}"); + } + if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { + let shown = match version { + Some(version) => session.version_reader(page, version)().unwrap(), + None => session.reader(page)().unwrap(), + }; + save( + &output.join(format!("{name}.png")), + &paint(&ui, shown, renderer, device, queue), + ); + } + } + // The newest version bands the line its author added since the version before it. + let newest = session.version_reader(page, versions[0])().unwrap(); + let banded: Vec<_> = newest + .objects + .iter() + .flat_map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline.paragraphs.clone(), + _ => Vec::new(), + }) + .filter(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CHANGED)) + .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned())) + .collect(); + assert_eq!(banded, ["Second author line."]); + session.section.close().unwrap(); + std::fs::remove_dir_all(&directory).unwrap(); +} diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ee3889c89a2a7de7973ceb4990793978806f195 --- /dev/null +++ b/crates/snowbound/src/history.rs @@ -0,0 +1,302 @@ +//! Page versions as OneNote 2010 shows them (Share, Page Versions): a page's earlier states +//! listed under it by date and author, each read-only under a yellow bar whose menu restores, +//! deletes or copies it. `corpus/page-versions` records OneNote's own. + +use super::*; +use onestore::PageVersion; + +/// Which versions a Delete All Versions deletes. +#[derive(Clone, Copy, PartialEq)] +pub enum Scope { + Section, + /// The section group holding the open section, with the groups inside it. + Group, + Notebook, +} + +impl Session { + /// A page's versions, newest first. + pub(crate) fn page_versions(&self, page: ExGuid) -> &[PageVersion] { + self.history + .iter() + .find(|(listed, _)| *listed == page) + .map_or(&[], |(_, versions)| versions) + } + + /// Whether the open page takes no edits: a conflict page or a version. + pub(crate) fn read_only(&self) -> bool { + self.version.is_some() || self.conflict(self.space).is_some() + } + + /// Version `version` of page `space` as the editor shows it: read-only, what it changed + /// since the version before it banded as OneNote bands it. + pub(crate) fn version_reader( + &self, + space: ExGuid, + version: ExGuid, + ) -> impl FnOnce() -> Result> + Send + 'static { + let replica = Arc::clone(self.section.replica()); + let versions = self.page_versions(space); + let older = versions + .iter() + .position(|listed| listed.context == version) + .and_then(|at| versions.get(at + 1)) + .map(|older| older.context); + move || { + let page = replica.version(space, version)?; + let older = older + .map(|older| replica.version(space, older)) + .transpose()?; + Ok(canvas::conflict::changes(page, older.as_ref())) + } + } + + pub(crate) fn refresh_history(&mut self) -> Result<(), Box> { + self.history = self.section.versions()?; + if self + .shown_history + .is_some_and(|page| self.page_versions(page).is_empty()) + { + self.shown_history = None; + } + Ok(()) + } + + /// Whether the open section lies in a section group; a section opened on its own has a + /// file path, not a catalog one. + pub(crate) fn grouped(&self) -> bool { + self.library.catalog().is_some() && self.tabs[self.tab].path.contains('/') + } +} + +/// A version's row in the page list: its date and who last changed it. +pub fn label(version: &PageVersion) -> String { + match (version.modified, &version.author) { + (Some(modified), Some(author)) => format!("{} {author}", platform::short_date(modified)), + (Some(modified), None) => platform::short_date(modified), + (None, author) => author.clone().unwrap_or_default(), + } +} + +/// The bar's menu over an open version, in OneNote 2010's words, and what its choice asks. +/// Copy Page To opens `copy`, listing the folder's sections. +pub fn menu( + ui: &mut Ui, + anchor: ui::Anchor, + [page, version]: [ExGuid; 2], + grouped: bool, + sections: &[&str], +) -> Option { + let menu = Id::ROOT.child("conflict-menu"); + let copy = Id::ROOT.child("conflict-copy"); + let item = |text| ui::popup::Item { + text, + ..Default::default() + }; + let items = [ + item("Restore Version"), + item("Delete Version"), + ui::popup::Item { + disabled: sections.is_empty(), + ..item("Copy Page To…") + }, + ui::popup::Item { + separated: true, + ..item("Delete All Versions in Section") + }, + ui::popup::Item { + disabled: !grouped, + ..item("Delete All Versions in Section Group") + }, + item("Delete All Versions in Notebook"), + ui::popup::Item { + separated: true, + ..item("Hide Page Versions") + }, + ]; + let chosen = match ui::popup::menu(ui, menu, anchor, &items, None) { + Some(0) => Some(Command::RestoreVersion { page, version }), + Some(1) => Some(Command::DeletePageVersion { page, version }), + Some(2) => { + ui.open_popup(copy); + None + } + Some(3) => Some(Command::DeleteAllVersions(Scope::Section)), + Some(4) => Some(Command::DeleteAllVersions(Scope::Group)), + Some(5) => Some(Command::DeleteAllVersions(Scope::Notebook)), + Some(_) => Some(Command::History { page, show: false }), + None => None, + }; + let targets: Vec<_> = sections.iter().map(|name| item(name)).collect(); + let tab = ui::popup::menu(ui, copy, anchor, &targets, None); + chosen.or(tab.map(|tab| Command::CopyVersion { version, tab })) +} + +impl State { + /// Shows or hides a page's versions under it; hiding leaves an open version for its page. + pub(crate) fn show_history(&mut self, page: ExGuid, show: bool) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("No section is open")?; + session.shown_history = show.then_some(page); + if !show && session.version.is_some() { + self.commands.push(Command::OpenPage(page)); + } + Ok(()) + } + + pub(crate) fn open_version( + &mut self, + page: ExGuid, + version: ExGuid, + ) -> Result<(), Box> { + let session = self.session.as_ref().ok_or("No section is open")?; + let read = session.version_reader(page, version); + self.load(move || Ok(Loaded::Version(page, version, read()?))); + Ok(()) + } + + /// Restore Version: the version becomes the page, and the page as it stood the newest + /// version. + pub(crate) fn restore_version( + &mut self, + page: ExGuid, + version: ExGuid, + ) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("No section is open")?; + session + .section + .restore_version(page, version, &self.author)?; + session.status = "Saving"; + session.refresh_history()?; + self.commands.push(Command::OpenPage(page)); + Ok(()) + } + + pub(crate) fn delete_version( + &mut self, + page: ExGuid, + version: ExGuid, + ) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("No section is open")?; + session.section.delete_versions(&[(page, vec![version])])?; + session.status = "Saving"; + session.refresh_history()?; + self.commands.push(Command::OpenPage(page)); + Ok(()) + } + + /// Delete All Versions in the section, its group or the notebook, once confirmed. + pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("No section is open")?; + let tab = &session.tabs[session.tab]; + let (open, section) = (tab.path.clone(), tab.name.clone()); + let folder = open + .rsplit_once('/') + .map_or("", |(folder, _)| folder) + .to_owned(); + let (container, name) = match scope { + Scope::Section => ("section", section), + Scope::Group => ( + "section group", + folder.rsplit('/').next().unwrap_or(&folder).to_owned(), + ), + Scope::Notebook => ("notebook", session.library.name.clone()), + }; + if !platform::confirm( + &format!("Do you want to delete all page versions in the {container} \"{name}\"?"), + "You can't restore these versions afterward.", + "Cancel", + "Delete Versions", + ) { + return Ok(()); + } + let all: Vec<(ExGuid, Vec)> = session + .history + .iter() + .map(|(page, versions)| { + ( + *page, + versions.iter().map(|version| version.context).collect(), + ) + }) + .collect(); + if !all.is_empty() { + session.section.delete_versions(&all)?; + session.status = "Saving"; + } + session.refresh_history()?; + if session.version.is_some() { + self.commands.push(Command::OpenPage(session.space)); + } + if scope == Scope::Section { + return Ok(()); + } + // The other sections, each opened, cleared and closed in the background. + let folder = match scope { + Scope::Group => folder, + _ => String::new(), + }; + let library = Arc::clone(&session.library); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + for path in sections(&library, &folder) { + if path == open { + continue; + } + let cleared = library + .open(&path, notify(proxy.clone())) + .and_then(|section| { + let all: Vec<(ExGuid, Vec)> = section + .versions()? + .into_iter() + .map(|(page, versions)| { + ( + page, + versions + .into_iter() + .map(|version| version.context) + .collect(), + ) + }) + .collect(); + if !all.is_empty() { + section.delete_versions(&all)?; + } + Ok(section.close()?) + }); + if let Err(error) = cleared { + eprintln!("Deleting the versions in {path} failed: {error}"); + } + } + }); + Ok(()) + } +} + +/// The readable sections at catalog path `folder` and in the groups inside it, leaving out +/// the recycle bin. +fn sections(library: &Library, folder: &str) -> Vec { + let Some(catalog) = library.catalog() else { + return vec![library.location.clone()]; + }; + let mut pending = vec![catalog]; + let mut root = None; + while let Some(candidate) = pending.pop() { + if candidate.path == folder { + root = Some(candidate); + break; + } + pending.extend(&candidate.groups); + } + let mut pending: Vec<_> = root.into_iter().collect(); + let mut paths = Vec::new(); + while let Some(folder) = pending.pop() { + paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path)); + pending.extend( + folder + .groups + .iter() + .filter(|group| !library::recycle_bin(&group.path)), + ); + } + paths +} diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 8c419811c8596e804b27461cd983f0e76a3ed61d..32e4c564dd6b3db98f800dc4530af7e084d0fda6 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -83,6 +83,7 @@ pub struct Login { impl Login { /// The guest account a mount without one signed in as. + #[cfg(any(target_os = "macos", test))] pub fn guest(mount: &Mount) -> Self { Self { user: String::new(), @@ -327,7 +328,12 @@ impl Library { if let Some(tab) = tabs(folder).into_iter().next() { return Some(tab.path); } - folders.extend(folder.groups.iter().filter(|group| !recycle_bin(group))); + folders.extend( + folder + .groups + .iter() + .filter(|group| !recycle_bin(&group.path)), + ); } None } @@ -356,9 +362,9 @@ impl Library { } } -/// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists. -pub fn recycle_bin(folder: &Folder) -> bool { - folder.path.rsplit('/').next() == Some("OneNote_RecycleBin") +/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. +pub fn recycle_bin(path: &str) -> bool { + path.rsplit('/').next() == Some("OneNote_RecycleBin") } pub fn file_name(path: &Path) -> String { diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb537b9dc7f0d78ca14e7ac49fd7dafe7a09a6ec --- /dev/null +++ b/crates/snowbound/src/link.rs @@ -0,0 +1,464 @@ +//! Links and equations on the page as OneNote 2010 offers them: the Link dialog, +//! the page's context menu, and following a clicked link, to a page +//! of the notebook or with the system's handler. + +use crate::{Command, State, page, platform}; +use onestore::ExGuid; +use onestore::page::link::{LinkTarget, internal_link}; +use std::error::Error; +use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; + +const WIDTH: f32 = 420.0; +/// Pages the dialog lists at once; the search narrows the rest. +const PAGES: usize = 8; + +/// The Link dialog's fields while it is open. +pub struct LinkDialog { + text: String, + address: String, + search: String, +} + +fn id() -> Id { + Id::ROOT.child("link") +} + +fn text_field() -> Id { + id().child("text") +} + +fn address_field() -> Id { + id().child("address") +} + +fn search_field() -> Id { + id().child("search") +} + +fn menu() -> Id { + Id::ROOT.child("text-menu") +} + +/// What a link's address opens with the system's handler: a web address typed without its +/// scheme is a web page, a share's `\\server\share` path its `smb:` URL. +fn system_url(address: &str) -> String { + if let Some(share) = address.strip_prefix("\\\\") { + return format!("smb://{}", share.replace('\\', "/")); + } + let scheme = address.split_once(':').is_some_and(|(scheme, _)| { + scheme.len() > 1 + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + }); + if scheme { + address.to_owned() + } else { + format!("http://{address}") + } +} + +/// A page, or a paragraph on it, as Copy Link to Page gives it: the section file's path +/// before the title, then the identities OneNote finds it by. +fn clipboard_link( + path: &str, + section: [u8; 16], + title: &str, + page: [u8; 16], + object: Option, +) -> String { + let target = match object { + Some(object) => LinkTarget::Object { + identity: page, + title, + object, + }, + None => LinkTarget::Page { + identity: page, + title, + }, + }; + let stored = internal_link(section, "", target); + let fragment = stored + .strip_prefix("onenote:#") + .and_then(|rest| rest.strip_suffix("&base-path=")) + .unwrap_or_default(); + format!("onenote:///{}#{fragment}", path.replace(' ', "%20")) +} + +impl State { + /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links. + pub(crate) fn open_link_dialog(&mut self) { + let (text, address) = self.view.link_prefill(); + self.link = Some(LinkDialog { + text, + address, + search: String::new(), + }); + self.ui.open_popup(id()); + self.ui.set_focus(Some(address_field())); + } + + /// Builds the Link dialog while it is open. OK, or Enter in a field, links the address + /// shown as the text; a page picked from the list fills both. + pub(crate) fn link_dialog(&mut self) -> Result<(), Box> { + let Some(dialog) = &mut self.link else { + return Ok(()); + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.link = None; + return Ok(()); + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let window = ui.rect(Id::ROOT).unwrap_or_default(); + let height = ui.rect(id()).map_or(360.0, |rect| rect[3] - rect[1]); + let [left, top] = [ + ((window[2] - WIDTH) / 2.0).max(0.0), + ((window[3] - height) / 2.0).max(0.0), + ]; + let fields = [text_field(), address_field(), search_field()]; + let entered = + ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 4.0, + anchor: Some(Anchor::Below([left, top, left, top])), + ..Spec::default() + }, + ); + let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| { + if !label.is_empty() { + ui.leaf( + label, + Spec { + size: [fill(), px(row * 0.8)], + text: Some(label), + ..Spec::default() + }, + ); + } + ui::text_field( + ui, + id, + value, + hint, + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + }; + field(ui, "Text to display:", text_field(), &mut dialog.text, ""); + field(ui, "Address:", address_field(), &mut dialog.address, ""); + field( + ui, + "Or pick a location in OneNote:", + search_field(), + &mut dialog.search, + "Search by text in title", + ); + let search = dialog.search.to_lowercase(); + let pages: Vec<(ExGuid, String)> = self + .session + .as_ref() + .map(|session| { + session + .pages + .iter() + .filter(|(_, title, _)| title.to_lowercase().contains(&search)) + .take(PAGES) + .map(|(space, title, _)| (*space, title.clone())) + .collect() + }) + .unwrap_or_default(); + ui.open( + "pages", + Spec { + axis: Axis::Y, + size: [fill(), px(row * PAGES as f32 + 8.0)], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [4.0, 4.0], + flags: Flags::CLIP, + ..Spec::default() + }, + ); + let mut picked = None; + for (space, title) in &pages { + let shown = if title.is_empty() { + "Untitled page" + } else { + title + }; + let spec = Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(row)], + text: Some(shown), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }; + if ui.leaf(space, spec).clicked { + picked = Some((*space, title.clone())); + } + } + ui.close(); + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let ok = ui::button(ui, "ok", "OK").clicked || entered; + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + ui.close(); + ui.close(); + if let Some((space, title)) = picked + && let Some(session) = &self.session + { + let page = session.section.page(space)?; + let identity = page + .identity + .ok_or("This page has no identity to link to")?; + let address = internal_link( + session.section.identity()?, + &session.section.file().to_string_lossy(), + LinkTarget::Page { + identity, + title: &title, + }, + ); + let dialog = self.link.as_mut().unwrap(); + dialog.address = address; + if dialog.text.is_empty() { + dialog.text = title; + } + return Ok(()); + } + let dialog = self.link.as_ref().unwrap(); + if ok && !dialog.address.trim().is_empty() { + let response = self.view.set_link(&dialog.text, &dialog.address)?; + self.respond(response); + } else if !cancel { + return Ok(()); + } + self.ui.close_popup(id()); + self.ui.set_focus(Some(page())); + self.link = None; + Ok(()) + } + + /// A secondary press on the page's text opens its context menu there. + pub(crate) fn open_text_menu(&mut self) -> Result<(), Box> { + let Some((response, context)) = self.view.context()? else { + return Ok(()); + }; + self.respond(response); + let point = self.ui.pointer().unwrap_or_default(); + self.text_menu = Some((context, point)); + self.ui.open_popup(menu()); + Ok(()) + } + + /// The page's context menu while it is open, with OneNote 2010's commands for text, + /// links and equations. + pub(crate) fn text_menu(&mut self) -> Result<(), Box> { + let Some((context, point)) = &self.text_menu else { + return Ok(()); + }; + if !self.ui.popup_open(menu()) { + self.text_menu = None; + return Ok(()); + } + let item = |text| Item { + text, + ..Item::default() + }; + let mut items = vec![ + Item { + disabled: !context.selected, + ..item("Cut") + }, + Item { + disabled: !context.selected, + ..item("Copy") + }, + item("Paste"), + ]; + match &context.link { + Some(_) => items.extend([ + Item { + separated: true, + ..item("Edit Link…") + }, + item("Copy Link to Paragraph"), + Item { + separated: true, + ..item("Copy Link") + }, + item("Select Link"), + item("Remove Link"), + ]), + None => items.extend([ + Item { + separated: true, + ..item("Link…") + }, + item("Copy Link to Paragraph"), + ]), + } + if context.equation { + items.extend([ + Item { + separated: true, + ..item("Professional") + }, + item("Linear"), + ]); + } + let Some(chosen) = + ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None) + else { + return Ok(()); + }; + let Some((context, _)) = self.text_menu.take() else { + return Ok(()); + }; + let response = match items[chosen].text { + "Cut" => self.view.copy(true)?, + "Copy" => self.view.copy(false)?, + "Paste" => { + self.commands + .push(Command::Page(canvas::interaction::Request::Paste)); + return Ok(()); + } + "Edit Link…" | "Link…" => { + self.open_link_dialog(); + return Ok(()); + } + "Copy Link" => { + self.clipboard.set_text(context.link.unwrap_or_default())?; + return Ok(()); + } + "Copy Link to Paragraph" => { + if let Some(session) = &self.session { + let link = self.page_link(session.space, context.paragraph)?; + self.clipboard.set_text(link)?; + } + return Ok(()); + } + "Select Link" => self.view.unlink(true)?, + "Remove Link" => self.view.unlink(false)?, + text => self.view.switch_equation(text == "Linear")?, + }; + self.respond(response); + Ok(()) + } + + /// Copy Link to Page, or to a paragraph `object` on it: the link OneNote puts on the + /// clipboard for page `space` of the open section. + pub(crate) fn page_link( + &self, + space: ExGuid, + object: Option, + ) -> Result> { + let session = self.session.as_ref().ok_or("No section is open")?; + let page = session.section.page(space)?; + let identity = page + .identity + .ok_or("This page has no identity to link to")?; + let title = session + .pages + .iter() + .find(|(listed, ..)| *listed == space) + .map_or(page.title.as_str(), |(_, title, _)| title.as_str()); + Ok(clipboard_link( + &session.section.file().to_string_lossy(), + session.section.identity()?, + title, + identity, + object, + )) + } + + /// A clicked link: a page or section of an open notebook opens here; anything else opens + /// with the system's handler. + pub(crate) fn open_link(&mut self, address: &str) -> Result<(), Box> { + if !address.to_ascii_lowercase().starts_with("onenote:") { + platform::reveal(system_url(address)); + return Ok(()); + } + let Some(session) = &self.session else { + return Ok(()); + }; + let library = std::sync::Arc::clone(&session.library); + let Ok(Some(notebook)) = &library.notebook else { + return Ok(()); + }; + let Some((path, space)) = notebook.find_page(address)? else { + return Ok(()); + }; + if path == session.tabs[session.tab].path { + self.commands.extend(space.map(Command::OpenPage)); + return Ok(()); + } + if let Some(space) = space { + self.last_pages.insert(library.key(&path), space); + } + self.commands.push(Command::OpenSection(library, path)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses_open_as_onenote_follows_them() { + assert_eq!(system_url("www.example.com"), "http://www.example.com"); + assert_eq!(system_url("example.net/x"), "http://example.net/x"); + assert_eq!(system_url("https://a.example/p"), "https://a.example/p"); + assert_eq!(system_url("mailto:me@example.com"), "mailto:me@example.com"); + assert_eq!(system_url("\\\\server\\share\\f"), "smb://server/share/f"); + } + + /// OneNote 2010's Copy Link to Page (`corpus/link-edit/native-typed`, README): the file's + /// path, the title, and the section and page identities. + #[test] + fn page_links_copy_as_onenote_copies_them() { + let section = *b"\x25\xfc\x1e\x0f\x7c\xdf\xcb\x45\x87\xa4\xbd\xd0\x3f\x42\x05\x7e"; + let page = *b"\x18\xa3\xcc\xa9\x3e\xa4\x12\x49\xa1\xc7\xf1\x9f\x1e\xaf\x30\xca"; + assert_eq!( + clipboard_link("C:\\one\\links.one", section, "Links", page, None), + "onenote:///C:\\one\\links.one#Links§ion-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end" + ); + } +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 0c6423436fc3bf0728d951a4861cac50597b8d5b..908d32b96c827f1ef0d9f158f7239bc8174c5b91 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -3,7 +3,16 @@ //! the C library's locale. use canvas::date::DateField; -use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration}; +use std::{ + ffi::CStr, + path::PathBuf, + process::Command, + sync::{ + OnceLock, + atomic::{AtomicU32, Ordering}, + }, + time::Duration, +}; use ui::{Flags, Spec, Ui, px}; use winit::{ error::EventLoopError, @@ -16,8 +25,6 @@ use winit::{ const APP_ID: &str = "snowbound"; /// The title bar's leading margin; the window controls sit at its trailing end. pub const LEADING: f32 = 8.0; -/// Square, as compositors that round windows don't report by how much. -pub const CORNER_RADIUS: f32 = 0.0; /// How far inside the window's edges a press resizes it, and how far along them a corner /// reaches, in logical pixels. const EDGE: f32 = 5.0; @@ -27,6 +34,9 @@ const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")] const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; static QUIT: OnceLock> = OnceLock::new(); +/// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where +/// KWin leaves windows square. +static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); pub fn event_loop(_headless: bool) -> Result, EventLoopError> { // Month and day names and date orders follow the user's locale; other categories stay C. @@ -34,9 +44,95 @@ pub fn event_loop(_headless: bool) -> Result, EventL let event_loop = EventLoop::with_user_event().build()?; QUIT.set(event_loop.create_proxy()) .expect("Only one application event loop is created"); + watch_settings(event_loop.create_proxy()); + if std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktop| desktop.contains("KDE")) { + follow_breeze_radius(event_loop.create_proxy()); + } Ok(event_loop) } +/// How far the window manager rounds the window's corners: KWin rounds the bottom corners +/// of Breeze's borderless windows unless they are maximized or full screen. +pub fn corner_radius(window: &Window) -> f32 { + let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed)); + if units == 0.0 + || !window.is_decorated() + || window.is_maximized() + || window.fullscreen().is_some() + { + return 0.0; + } + // Breeze snaps its radius to the device pixel grid. + let scale = window.scale_factor() as f32; + (units * scale).round() / scale +} + +/// Reads Breeze's corner radius now and again whenever KWin is told to reload its +/// configuration, as Breeze's and the border size's settings pages do, redrawing then. +fn follow_breeze_radius(proxy: EventLoopProxy) { + let Ok(connection) = zbus::blocking::connection::Builder::session() + .and_then(|builder| builder.method_timeout(Duration::from_millis(500)).build()) + else { + return; + }; + let read = |connection: &zbus::blocking::Connection| { + let units = breeze_radius(connection).unwrap_or(0.0); + BREEZE_RADIUS.store(units.to_bits(), Ordering::Relaxed); + }; + // Before the window's first frame, so its corners never change under the user. + read(&connection); + std::thread::spawn(move || { + let Ok(rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.kde.KWin") + .and_then(|rule| rule.member("reloadConfig")) + .map(|rule| rule.build()) + else { + return; + }; + let Ok(signals) = + zbus::blocking::MessageIterator::for_match_rule(rule, &connection, Some(8)) + else { + return; + }; + for _ in signals { + read(&connection); + let _ = proxy.send_event(crate::UserEvent::Redraw); + } + }); +} + +/// Breeze's `Frame_FrameRadius` of 2.5 small spacings where KWin 6.5 or later decorates +/// with Breeze, borderless and with rounded corners, as Breeze's own +/// `Decoration::recalculateBorders` decides. +fn breeze_radius(connection: &zbus::blocking::Connection) -> Option { + let proxy = + zbus::blocking::Proxy::new(connection, "org.kde.KWin", "/KWin", "org.kde.KWin").ok()?; + let info: String = proxy.call("supportInformation", &()).ok()?; + let field = |name: &str| { + info.lines() + .find_map(|line| line.strip_prefix(name)?.strip_prefix(": ")) + }; + let mut version = field("KWin version")?.split('.').map(str::parse::); + let (Some(Ok(major)), Some(Ok(minor))) = (version.next(), version.next()) else { + return None; + }; + let breezerc = kconfig("breezerc"); + let rounded = kconfig_entries(&breezerc) + .get(&("Common", "RoundedCorners")) + .is_none_or(|value| *value != "false"); + // KDecoration's BorderSize::None is 0. + let applies = (major, minor) >= (6, 5) + && field("Plugin")? == "org.kde.breeze" + && field("borderSize")? == "0" + && rounded; + Some(if applies { + 2.5 * field("smallSpacing")?.parse::().ok()? + } else { + 0.0 + }) +} + /// Decorated by the window manager, or on Wayland by the compositor where it offers /// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies /// the icon on Wayland, while X11 takes it from the window. @@ -68,7 +164,10 @@ impl Clipboard { pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { match self { - Self::Wayland(clipboard) => Ok(clipboard.store(text)), + Self::Wayland(clipboard) => { + clipboard.store(text); + Ok(()) + } Self::X11(clipboard) => clipboard.set_text(text), } } @@ -87,6 +186,109 @@ pub fn system_titlebar(window: &Window) -> bool { window.is_decorated() } +/// Window managers show no document of a window's. +pub fn represent(_: &Window, _: Option<&std::path::Path>) {} + +/// None: the title bars desktops draw are opaque. +pub fn install_backdrop(_: &Window) -> bool { + false +} + +/// The title bar's fills with the window focused and not, where the desktop's are known and +/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar +/// that winit's frame and GNOME's both draw. +pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { + let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let on = |name: &str| desktops.split(':').any(|desktop| desktop == name); + let fills = if on("KDE") { + kde_titlebar(&kconfig("kdeglobals")) + } else if on("GNOME") { + adwaita_titlebar(appearance) + } else { + return None; + } + .map(|[red, green, blue]| draw::srgb(red, green, blue)); + // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip. + let [red, green, blue, _] = fills[0]; + let dark = 0.2126 * red + 0.7152 * green + 0.0722 * blue < 0.18; + (dark == (appearance == Theme::Dark)).then_some(fills) +} + +/// KConfig file `name` from the system's configuration directories up to the user's, in +/// rising precedence. +fn kconfig(name: &str) -> Vec { + let system = std::env::var("XDG_CONFIG_DIRS") + .ok() + .filter(|dirs| !dirs.is_empty()) + .unwrap_or_else(|| "/etc/xdg".into()); + let mut dirs: Vec = std::env::split_paths(&system).collect(); + dirs.reverse(); + dirs.extend(xdg_base("XDG_CONFIG_HOME", ".config")); + dirs.iter() + .filter_map(|dir| std::fs::read_to_string(dir.join(name)).ok()) + .collect() +} + +/// The entries of KConfig `files` in rising precedence, by group and key. +fn kconfig_entries(files: &[String]) -> std::collections::HashMap<(&str, &str), &str> { + let mut entries = std::collections::HashMap::new(); + for file in files { + let mut group = ""; + for line in file.lines().map(str::trim) { + if let Some(name) = line + .strip_prefix('[') + .and_then(|line| line.strip_suffix(']')) + { + group = name; + } else if let Some((key, value)) = line.split_once('=') { + entries.insert((group, key.trim()), value.trim()); + } + } + } + entries +} + +/// KWin's title bar fills, focused and not, from kdeglobals `files` in rising precedence: the +/// colour scheme's header colours where it has them, as KWin prefers, else its window +/// manager's, else Breeze Light's header. +fn kde_titlebar(files: &[String]) -> [[u8; 3]; 2] { + let entries = kconfig_entries(files); + let color = |group, key| kde_color(entries.get(&(group, key))?); + let pair = |active: [u8; 3], inactive: Option<[u8; 3]>| [active, inactive.unwrap_or(active)]; + if let Some(active) = color("Colors:Header", "BackgroundNormal") { + pair(active, color("Colors:Header][Inactive", "BackgroundNormal")) + } else if let Some(active) = color("WM", "activeBackground") { + pair(active, color("WM", "inactiveBackground")) + } else { + [[222, 224, 226], [239, 240, 241]] + } +} + +/// A KConfig colour: `r,g,b` with an optional alpha, or `#rrggbb`. +fn kde_color(value: &str) -> Option<[u8; 3]> { + if let Some(hex) = value.strip_prefix('#') { + let [_, red, green, blue] = u32::from_str_radix(hex, 16) + .ok() + .filter(|_| hex.len() == 6)? + .to_be_bytes(); + return Some([red, green, blue]); + } + let mut channels = value.split(',').map(|channel| channel.trim().parse().ok()); + Some([channels.next()??, channels.next()??, channels.next()??]) +} + +/// The header bar of winit's Adwaita frame, focused and not. +fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { + let theme = match appearance { + Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(), + Theme::Light => sctk_adwaita::theme::ColorTheme::light(), + }; + [theme.active, theme.inactive].map(|colors| { + let color = colors.headerbar.to_color_u8(); + [color.red(), color.green(), color.blue()] + }) +} + /// Minimize, maximize and close at the title bar's trailing end. pub fn window_controls(ui: &mut Ui, window: &Window) { let theme = ui.theme.clone(); @@ -166,15 +368,15 @@ pub fn install_text_input(_: &Window) {} /// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. pub fn install_menu() {} +/// The toolbar and keyboard stand in for a menu bar. +pub fn update_menu(_: impl FnOnce() -> Vec) {} + pub fn clear_marked_text(_: &Window) {} pub fn configure_presentation(_: &wgpu::Surface<'_>) {} pub fn commit_presentation(_: &Window) {} -/// GNOME has no character palette to open from an app. -pub fn show_character_palette() {} - /// GTK's default double-click time. pub fn double_click_interval() -> Duration { Duration::from_millis(400) @@ -188,6 +390,41 @@ pub fn appearance(_: &Window) -> Theme { if dark { Theme::Dark } else { Theme::Light } } +/// Asks for the desktop's colours again whenever the settings portal reports a change to the +/// colour scheme, or to KDE's colours. +fn watch_settings(proxy: EventLoopProxy) { + std::thread::spawn(move || { + let Ok(connection) = zbus::blocking::Connection::session() else { + return; + }; + let Ok(portal) = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + ) else { + return; + }; + let Ok(changes) = portal.receive_signal("SettingChanged") else { + return; + }; + for change in changes { + let Ok((namespace, _, _)) = change + .body() + .deserialize::<(String, String, zbus::zvariant::OwnedValue)>() + else { + continue; + }; + if (namespace == "org.freedesktop.appearance" + || namespace.starts_with("org.kde.kdeglobals")) + && proxy.send_event(crate::UserEvent::Appearance).is_err() + { + return; + } + } + }); +} + fn portal_color_scheme() -> Option { use zbus::zvariant::{OwnedValue, Value}; // A missing portal must not hold up the window for D-Bus's 25 s default. @@ -232,14 +469,22 @@ pub fn smb_mount(path: &std::path::Path) -> Option { .lines() .filter_map(|line| { let mut fields = line.split_whitespace(); - let (source, point, kind, options) = - (fields.next()?, fields.next()?, fields.next()?, fields.next()?); + let (source, point, kind, options) = ( + fields.next()?, + fields.next()?, + fields.next()?, + fields.next()?, + ); let point = point.replace("\\040", " "); (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) .then(|| (source.replace("\\040", " "), point, options.to_owned())) }) .max_by_key(|(_, point, _)| point.len())?; - let within = path.strip_prefix(&point).ok()?.to_string_lossy().into_owned(); + let within = path + .strip_prefix(&point) + .ok()? + .to_string_lossy() + .into_owned(); crate::library::Mount::parse(&source, &within, &options) } @@ -258,7 +503,9 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result Result Option { /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. fn xdg_dir(variable: &str, fallback: &str) -> Option { + Some(xdg_base(variable, fallback)?.join(APP_ID)) +} + +fn xdg_base(variable: &str, fallback: &str) -> Option { std::env::var_os(variable) .map(PathBuf::from) .filter(|path| path.is_absolute()) .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) - .map(|base| base.join(APP_ID)) } /// The application's icon from the icon theme, the smallest at least `pixels` square. @@ -543,10 +793,11 @@ fn dialog( .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } -/// Opens `folder` in the file manager. -pub fn reveal(folder: &std::path::Path) { - if let Err(error) = Command::new("xdg-open").arg(folder).spawn() { - eprintln!("Cannot open {}: {error}", folder.display()); +/// Opens `target`, a folder or a link's URL, with the desktop's handler. +pub fn reveal(target: impl AsRef) { + let target = target.as_ref(); + if let Err(error) = Command::new("xdg-open").arg(target).spawn() { + eprintln!("Cannot open {}: {error}", target.display()); } } @@ -585,7 +836,13 @@ pub fn pick_notebook(title: &str) -> Option { &format!("--title={title}"), "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one", ], - ["--getopenfilename", ".", "*.onetoc2 *.one", "--title", title], + [ + "--getopenfilename", + ".", + "*.onetoc2 *.one", + "--title", + title, + ], ); asked .unwrap_or_else(|error| { @@ -675,4 +932,44 @@ mod tests { assert_eq!(locale_tag("de_DE"), "de-DE"); assert_eq!(locale_tag("C.UTF-8"), ""); } + + #[test] + fn kwin_title_bars_take_the_colour_scheme_s_header_then_its_window_manager_colours() { + let breeze_dark = "[Colors:Header]\nBackgroundNormal=41,44,48\nForegroundNormal=252,252,252\n\ + [Colors:Header][Inactive]\nBackgroundNormal=32,35,38\n\ + [Colors:Window]\nBackgroundNormal=32,35,38\n\ + [WM]\nactiveBackground=49,54,59\ninactiveBackground=42,46,50\n"; + assert_eq!( + kde_titlebar(&[breeze_dark.into()]), + [[41, 44, 48], [32, 35, 38]] + ); + let legacy = "[General]\nColorScheme=Old\n\n[WM]\nactiveBackground=48,174,232\n\ + inactiveBackground=#eff0f1\n"; + assert_eq!( + kde_titlebar(&[legacy.into()]), + [[48, 174, 232], [239, 240, 241]] + ); + let header_only = "[Colors:Header]\nBackgroundNormal = 10, 20, 30, 255\n"; + assert_eq!(kde_titlebar(&[header_only.into()]), [[10, 20, 30]; 2]); + // The user's file, last, overrides the system's. + assert_eq!( + kde_titlebar(&[breeze_dark.into(), header_only.into()]), + [[10, 20, 30], [32, 35, 38]] + ); + assert_eq!( + kde_titlebar(&["# no colours\n[KDE]\nLookAndFeelPackage=x\n".into()]), + [[222, 224, 226], [239, 240, 241]], + "Breeze Light's header by default" + ); + } + + #[test] + fn kconfig_colours_read_as_channels_or_hex() { + assert_eq!(kde_color("222,224,226"), Some([222, 224, 226])); + assert_eq!(kde_color("1, 2, 3, 128"), Some([1, 2, 3])); + assert_eq!(kde_color("#2a2e32"), Some([0x2a, 0x2e, 0x32])); + assert_eq!(kde_color("#2a2e"), None); + assert_eq!(kde_color("256,0,0"), None); + assert_eq!(kde_color("1,2"), None); + } } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 7b48ce64ad94239aafa821c1d4314077e243da98..fcadb67f6c1546741bc2fd46e41478c5c35a6ae2 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -1,3 +1,4 @@ +use crate::commands; use canvas::date::DateField; use objc2::{ ClassType, DeclaredClass, @@ -14,9 +15,12 @@ use objc2_app_kit::{ }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, - NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSSize, NSString, + NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSString, +}; +use std::{ + cell::{Cell, RefCell}, + sync::OnceLock, }; -use std::{cell::Cell, sync::OnceLock}; use winit::{ error::EventLoopError, event_loop::{EventLoop, EventLoopProxy}, @@ -27,7 +31,12 @@ use winit::{ static QUIT: OnceLock> = OnceLock::new(); static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); -thread_local! { static IN_KEY_DOWN: Cell = const { Cell::new(false) }; } +static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); +static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); +thread_local! { + static IN_KEY_DOWN: Cell = const { Cell::new(false) }; + static STATUSES: RefCell> = const { RefCell::new(Vec::new()) }; +} unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { let previous = IN_KEY_DOWN.replace(true); @@ -77,11 +86,14 @@ pub const LEADING: f32 = 78.0; /// How far AppKit rounds a window's corners. pub const CORNER_RADIUS: f32 = 10.0; +pub fn corner_radius(_: &Window) -> f32 { + CORNER_RADIUS +} + /// A transparent title bar over the content, where the window draws its own. pub fn window_attributes() -> WindowAttributes { Window::default_attributes() .with_titlebar_transparent(true) - .with_title_hidden(true) .with_fullsize_content_view(true) } @@ -109,6 +121,96 @@ pub fn system_titlebar(_: &Window) -> bool { false } +unsafe extern "C" fn no_document_drag( + _: &AnyObject, + _: Sel, + _: &AnyObject, + _: &AnyObject, + _: NSPoint, + _: &AnyObject, +) -> objc2::runtime::Bool { + objc2::runtime::Bool::NO +} + +/// Names `file` as the window's document: the title shows its icon, and Command-clicking the +/// title lists the folders it lies in. Dragging the icon, which could move the file out of its +/// notebook, is off. +pub fn represent(window: &Window, file: Option<&std::path::Path>) { + let window = ns_window(window); + unsafe { + let delegate: Option> = msg_send_id![&window, delegate]; + if let Some(delegate) = delegate { + let class = DELEGATE_CLASS.get_or_init(|| { + let mut class = ClassBuilder::new("SnowboundWindowDelegate", delegate.class()) + .expect("Unique window delegate class"); + class.add_method( + sel!(window:shouldDragDocumentWithEvent:from:withPasteboard:), + no_document_drag as unsafe extern "C" fn(_, _, _, _, _, _) -> _, + ); + class.register() + }); + if delegate.class() != *class { + assert_eq!(class.superclass(), Some(delegate.class())); + assert_eq!(class.instance_size(), delegate.class().instance_size()); + // The subclass adds no ivars, so the existing allocation remains valid. + AnyObject::set_class(&delegate, class); + } + } + let url = file.map(|file| { + objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&file.to_string_lossy())) + }); + let _: () = msg_send![&window, setRepresentedURL: url.as_deref()]; + } +} + +/// Nothing the backdrop leaves to colour. +pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> { + None +} + +unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyObject { + std::ptr::null_mut() +} + +/// Lays the title bar's material under the window's content, where it shows through the +/// app's transparent pixels as AppKit's title bars and toolbars show it: tinted by the +/// desktop, and following the window's appearance and whether it is key. Presses pass +/// through it to the view it lies in. +pub fn install_backdrop(window: &Window) -> bool { + MainThreadMarker::new().expect("Views belong to the main thread"); + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let class = BACKDROP_CLASS.get_or_init(|| { + let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked"); + let mut class = + ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); + class.add_method( + sel!(hitTest:), + hit_nothing as unsafe extern "C" fn(_, _, _) -> _, + ); + class.register() + }); + let bounds: NSRect = msg_send![view, bounds]; + let backdrop: Allocated = msg_send_id![*class, alloc]; + let backdrop: Retained = msg_send_id![backdrop, initWithFrame: bounds]; + // NSVisualEffectMaterialTitlebar, NSVisualEffectBlendingModeWithinWindow (which + // AppKit's own title bar measures as, over the window's desktop-tinted background) + // and NSVisualEffectStateFollowsWindowActiveState. + let _: () = msg_send![&backdrop, setMaterial: 3isize]; + let _: () = msg_send![&backdrop, setBlendingMode: 1isize]; + let _: () = msg_send![&backdrop, setState: 0isize]; + // NSViewWidthSizable | NSViewHeightSizable + let _: () = msg_send![&backdrop, setAutoresizingMask: 18usize]; + let _: () = msg_send![view, addSubview: &*backdrop]; + } + true +} + /// AppKit's window frame takes resizing presses. pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { None @@ -157,7 +259,10 @@ unsafe extern "C" { password: *mut *mut std::ffi::c_void, item: *mut *const std::ffi::c_void, ) -> i32; - fn SecKeychainItemFreeContent(list: *const std::ffi::c_void, data: *mut std::ffi::c_void) -> i32; + fn SecKeychainItemFreeContent( + list: *const std::ffi::c_void, + data: *mut std::ffi::c_void, + ) -> i32; } /// The password the keychain keeps for `mount`'s account on its server, as macOS saved it @@ -307,11 +412,6 @@ pub fn double_click_interval() -> std::time::Duration { std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() }) } -pub fn show_character_palette() { - let mtm = MainThreadMarker::new().expect("Text input belongs to the main thread"); - NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None); -} - pub fn edit_date( timestamp: u64, field: DateField, @@ -502,10 +602,10 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { MainThreadMarker::new().expect("Layer presentation belongs to the main thread"); // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions. if let Some(surface) = unsafe { surface.as_hal::() } { - surface - .render_layer() - .lock() - .setPresentsWithTransaction(true); + let layer = surface.render_layer().lock(); + layer.setPresentsWithTransaction(true); + // Over the backdrop's layer, which AppKit orders after it. + layer.setZPosition(1.0); } } @@ -520,54 +620,9 @@ pub fn commit_presentation(window: &Window) { } } -/// The application's icon as the Dock shows it, `pixels` square. -pub fn app_icon(pixels: u32) -> Option { - let side = pixels as usize; - let mut rgba = unsafe { - let mtm = MainThreadMarker::new()?; - let icon: Retained = - msg_send_id![&NSApplication::sharedApplication(mtm), applicationIconImage]; - let bitmap: Allocated = msg_send_id![AnyClass::get("NSBitmapImageRep")?, alloc]; - let planes: *mut *mut u8 = std::ptr::null_mut(); - let bitmap: Option> = msg_send_id![ - bitmap, - initWithBitmapDataPlanes: planes, - pixelsWide: side as isize, - pixelsHigh: side as isize, - bitsPerSample: 8isize, - samplesPerPixel: 4isize, - hasAlpha: true, - isPlanar: false, - colorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace"), - bytesPerRow: 4 * side as isize, - bitsPerPixel: 32isize - ]; - let bitmap = bitmap?; - let contexts = AnyClass::get("NSGraphicsContext")?; - let context: Option> = - msg_send_id![contexts, graphicsContextWithBitmapImageRep: &*bitmap]; - let _: () = msg_send![contexts, saveGraphicsState]; - let _: () = msg_send![contexts, setCurrentContext: &*context?]; - let bounds = NSRect::new( - NSPoint::new(0.0, 0.0), - NSSize::new(side as f64, side as f64), - ); - // NSCompositingOperationSourceOver - let _: () = msg_send![&icon, drawInRect: bounds, fromRect: NSRect::ZERO, operation: 2usize, fraction: 1.0f64]; - let _: () = msg_send![contexts, restoreGraphicsState]; - let data: *const u8 = msg_send![&bitmap, bitmapData]; - std::slice::from_raw_parts(data, 4 * side * side).to_vec() - }; - // AppKit's bitmap is premultiplied. - for pixel in rgba.chunks_exact_mut(4) { - let alpha = u32::from(pixel[3]); - for channel in &mut pixel[..3] { - *channel = (u32::from(*channel) * 255) - .checked_div(alpha) - .map_or(0, |value| value.min(255) as u8); - } - } - draw::RasterImage::new([pixels; 2], rgba).ok() +/// None: AppKit's title shows the document's icon in its place. +pub fn app_icon(_: u32) -> Option { + None } /// The insertion point's colour and selected text's fill with and without keyboard focus, @@ -635,9 +690,28 @@ declare_class!( if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } } - #[method(showOptions:)] - fn show_options(&self, _sender: Option<&AnyObject>) { - if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Options); } + #[method(choose:)] + fn choose(&self, sender: &NSMenuItem) { + let tag = usize::try_from(unsafe { sender.tag() }).ok(); + if let (Some(proxy), Some(choice)) = (QUIT.get(), tag.and_then(|tag| commands::choices().nth(tag))) { + let _ = proxy.send_event(crate::UserEvent::Choose(choice)); + } + } + + #[method(validateMenuItem:)] + fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool { + if unsafe { item.action() } != Some(sel!(choose:)) { + // AppKit's own items validate as NSApplication does. + return objc2::runtime::Bool::new( + !NSApplication::class().responds_to(sel!(validateMenuItem:)) + || unsafe { msg_send![super(self), validateMenuItem: item] }, + ); + } + let status = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| { + STATUSES.with_borrow(|statuses| statuses.get(tag).copied()) + }).unwrap_or_default(); + unsafe { item.setState(isize::from(status.checked)) }; + objc2::runtime::Bool::new(status.enabled) } // NSAlert consumes Escape before it reaches cancelOperation:. @@ -665,6 +739,7 @@ pub fn event_loop(headless: bool) -> Result, EventLo let _: Retained = unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; let mut builder = EventLoop::with_user_event(); + builder.with_default_menu(false); if headless { builder .with_activation_policy(ActivationPolicy::Prohibited) @@ -676,34 +751,30 @@ pub fn event_loop(headless: bool) -> Result, EventLo Ok(event_loop) } -/// Adds Settings… to the application menu Winit made, below About as macOS places it. +/// Replaces Winit's application menu with the menu bar, whose items the table validates. pub fn install_menu() { let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); let app = NSApplication::sharedApplication(mtm); - // Winit's menu is plain AppKit objects on the main thread. + let menus = crate::menubar::build(mtm, &app); + app.setMainMenu(Some(&menus.bar)); + // The bar holds these for as long as the application runs. unsafe { - let Some(menu) = app - .mainMenu() - .and_then(|bar| bar.itemAtIndex(0)) - .and_then(|item| item.submenu()) - else { - return; - }; - let settings = NSMenuItem::initWithTitle_action_keyEquivalent( - mtm.alloc(), - &NSString::from_str("Settings…"), - Some(sel!(showOptions:)), - &NSString::from_str(","), - ); - menu.insertItem_atIndex(&settings, 2); - menu.insertItem_atIndex(&NSMenuItem::separatorItem(mtm), 3); + app.setServicesMenu(Some(&menus.services)); + app.setWindowsMenu(Some(&menus.window)); + app.setHelpMenu(Some(&menus.help)); } } -/// Opens `folder` in Finder. -pub fn reveal(folder: &std::path::Path) { - if let Err(error) = std::process::Command::new("open").arg(folder).spawn() { - eprintln!("Cannot open {}: {error}", folder.display()); +/// The table's statuses, in `commands::choices` order, which the menu bar's items show. +pub fn update_menu(statuses: impl FnOnce() -> Vec) { + STATUSES.set(statuses()); +} + +/// Opens `target`, a folder or a link's URL, as Finder would. +pub fn reveal(target: impl AsRef) { + let target = target.as_ref(); + if let Err(error) = std::process::Command::new("open").arg(target).spawn() { + eprintln!("Cannot open {}: {error}", target.display()); } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 6c76877efbbf48280c4c7ea9d2a8e46b28b9e24e..c5f65d1df87bbbc346118f69ef8ad86fe1a9748b 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,14 +1,21 @@ mod art; +mod commands; #[cfg(test)] mod conflict_render; +mod history; mod library; +mod link; mod manage; mod meeting; +#[cfg(target_os = "macos")] +mod menubar; mod menus; +mod navigation; mod options; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod platform; +mod rename; mod screenshot; mod search; mod settings; @@ -28,11 +35,11 @@ use canvas::{ outline::TagIcon, }; use draw::Renderer; +use library::Library; use onestore::ExGuid; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; -use library::Library; use std::{ collections::{HashMap, HashSet}, error::Error, @@ -43,7 +50,7 @@ use std::{ use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; use winit::{ application::ApplicationHandler, - dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, + dpi::{LogicalPosition, LogicalSize}, event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, keyboard::{Key, ModifiersState, NamedKey}, @@ -55,23 +62,37 @@ const TITLE: f32 = 28.0; const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page. const FRAME: f32 = 6.0; -/// The page's corners share their centres with the window's. -const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0); const PAGE_LIST: f32 = 240.0; /// Font sizes the size box offers, OneNote's list in points. /// Fonts the font box offers first, OneNote's default leading. const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; -/// Fonts picked this session that the font box offers after `FONTS`, at most. -const RECENT_FONTS: usize = 5; +/// Picks the font box and the list galleries offer as recent, at most, as OneNote's +/// galleries do. +const RECENT: usize = 5; +/// Height of a line in a numbering gallery's previews. +const NUMBER_LINE: f32 = 16.0; /// Font sizes the size box offers, OneNote's list in points. const SIZES: [f32; 17] = [ 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0, ]; /// OneNote's highlight colours, COLORREF. -const HIGHLIGHTS: &[u32] = &[ - 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000, - 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000, +const HIGHLIGHTS: [(u32, &str); 15] = [ + (0x00ffff, "Yellow"), + (0x00ff00, "Bright Green"), + (0xffff00, "Turquoise"), + (0xff00ff, "Pink"), + (0xff0000, "Blue"), + (0x0000ff, "Red"), + (0x800000, "Dark Blue"), + (0x808000, "Teal"), + (0x008000, "Green"), + (0x800080, "Violet"), + (0x000080, "Dark Red"), + (0x008080, "Dark Yellow"), + (0x808080, "Gray 50%"), + (0xc0c0c0, "Gray 25%"), + (0x000000, "Black"), ]; /// Office's theme and standard font colours, COLORREF. const FONT_COLORS: [u32; 20] = [ @@ -85,8 +106,6 @@ const ROW: f32 = 29.0; const ROW_GAP: f32 = 3.0; /// Space between the page and a page tab that isn't open. const PILL_MARGIN: f32 = 3.0; -/// Rounding where the open page tab meets the page, concentric with its neighbours'. -const JOIN: f32 = PILL_MARGIN + ROUNDING; /// Why the platform's date dialog could not change the page's date. const DATE_UNCHOSEN: &str = "Choose another date or time."; const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; @@ -104,9 +123,12 @@ enum UserEvent { Replay(Replay), /// A page background finished rasterizing on its worker thread. Redraw, - /// The app menu's Settings item was chosen. + /// A menu bar item was chosen. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] - Options, + Choose(commands::Choice), + /// The desktop's colours changed, where the window system doesn't say so itself. + #[cfg_attr(target_os = "macos", allow(dead_code))] + Appearance, } /// Asks the event loop for a frame from any thread. @@ -166,8 +188,6 @@ struct App { proxy: EventLoopProxy, input: Option, substitutes: Vec, - /// Font files for the interface in place of the system's. - interface_font: Vec>, /// Where `--screenshot` writes its PNGs, drawn from a window never shown. screenshot: Option, /// What the window starts from besides its input, until it opens. @@ -220,13 +240,31 @@ struct Session { shown: Option, /// Which of the open conflict page's conflicting changes is selected, in page order. change: Option, + /// Each page's versions, newest first. + history: Vec<(ExGuid, Vec)>, + /// The page whose versions the list shows beneath it (Show Page Versions). + shown_history: Option, + /// The version of page `space` open, by its context. + version: Option, } /// The information bar above a page with conflict pages, or above one of them. #[derive(Clone, Copy)] enum Bar { - Page { page: ExGuid, shown: bool }, - Version { page: ExGuid, version: ExGuid }, + Page { + page: ExGuid, + shown: bool, + }, + Version { + page: ExGuid, + version: ExGuid, + }, + /// Above a page version, of a section `grouped` in a section group. + History { + page: ExGuid, + version: ExGuid, + grouped: bool, + }, } impl Session { @@ -261,11 +299,21 @@ impl Session { /// The page conflict page `space` belongs to, with it. fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> { self.conflicts.iter().find_map(|(page, versions)| { - Some((*page, versions.iter().find(|version| version.space == space)?)) + Some(( + *page, + versions.iter().find(|version| version.space == space)?, + )) }) } fn bar(&self) -> Option { + if let Some(version) = self.version { + return Some(Bar::History { + page: self.space, + version, + grouped: self.grouped(), + }); + } match self.conflict(self.space) { Some((page, _)) => Some(Bar::Version { page, @@ -291,7 +339,10 @@ impl Session { /// The page in `space` as the editor shows it: a conflict page read-only, its /// conflicting changes highlighted. - fn reader(&self, space: ExGuid) -> impl FnOnce() -> Result> + Send + 'static { + fn reader( + &self, + space: ExGuid, + ) -> impl FnOnce() -> Result> + Send + 'static { let replica = Arc::clone(self.section.replica()); let objects = self .conflict(space) @@ -311,14 +362,21 @@ impl Session { /// section has in it. enum Loaded { Section(Box, Page), + /// A section just created, whose name opens for renaming as OneNote's New Section does. + Created(Box, Page), Page(ExGuid, Page), - Library(Arc, String), + /// A page's version, by the page's space and the version's context. + Version(ExGuid, ExGuid, Page), + /// A notebook read again after a change, at the section that stays open, if any. + Library(Arc, Option), } /// What an opening page replaces: the section, or the page of the open one. enum Shown { Section(Box), + Created(Box), Page(ExGuid), + Version(ExGuid, ExGuid), } /// A loader thread's request number and what it read, its page laid out. @@ -326,7 +384,8 @@ type Read = (u64, Result); enum Laid { Page(Box), - Library(Arc, String), + /// A notebook read again after a change, at the section that stays open, if any. + Library(Arc, Option), } /// The newest page read, laid out and waiting for the pictures it shows first, so it @@ -355,24 +414,60 @@ enum Command { Structure(Arc, manage::Structure), /// Adds a page at the end of the open section, or a subpage of a page, and edits its /// title. - NewPage { under: Option }, + NewPage { + under: Option, + }, /// Deletes pages of the open section to the notebook's recycle bin. DeletePages(Vec), /// Moves or indents pages of the open section. Pages(Vec), /// Moves a page of the open section to the end of another section of its folder. - MovePage { space: ExGuid, path: String }, + MovePage { + space: ExGuid, + path: String, + }, /// Gives the open page a template's background or colour. Template(templates::Choice), OpenPage(ExGuid), /// Shows a page's conflict pages in the list and opens the newest, or hides them. - Versions { page: ExGuid, show: bool }, - DeleteVersion { page: ExGuid, version: ExGuid }, + Versions { + page: ExGuid, + show: bool, + }, + DeleteVersion { + page: ExGuid, + version: ExGuid, + }, /// Copies a conflict page, as a page of its own, to the end of section tab `tab`. - CopyVersion { version: ExGuid, tab: usize }, + CopyVersion { + version: ExGuid, + tab: usize, + }, /// Selects the next, or previous, conflicting change on the open conflict page. - SelectChange { forward: bool }, + SelectChange { + forward: bool, + }, + /// Shows or hides a page's versions in the list. + History { + page: ExGuid, + show: bool, + }, + OpenVersion { + page: ExGuid, + version: ExGuid, + }, + RestoreVersion { + page: ExGuid, + version: ExGuid, + }, + DeletePageVersion { + page: ExGuid, + version: ExGuid, + }, + DeleteAllVersions(history::Scope), Page(Request), + /// Runs a command, or one of a list's entries, after the frame's input. + Choose(commands::Choice), } struct State { @@ -392,6 +487,8 @@ struct State { places: HashMap<(String, ExGuid), Place>, /// The page each section showed last this run, by `Library::key`. last_pages: HashMap, + /// Pages visited, for Back and Forward. + trail: navigation::Trail, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, instance: wgpu::Instance, @@ -417,10 +514,21 @@ struct State { menu: Option<(menus::Target, [f32; 2])>, /// The Options dialog's choices while it is open. options: Option, + /// The Link dialog's fields while it is open. + link: Option, + /// The page's context menu while it is open: what it was opened on, and where. + text_menu: Option<(canvas::interaction::Context, [f32; 2])>, color_scheme: settings::ColorScheme, light_pages: bool, + /// Carries frames to the window where the system's backdrop shows through the strip. + translucent: Option, + /// The strip's fill with the window focused and not, continuing the system's title bar. + titlebar: [[f32; 4]; 2], /// A section or group being renamed in the sidebar. - renaming: Option, + renaming: Option, + /// The notebook shown while it has no section open, as one whose last section was + /// deleted. + sectionless: Option>, /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. drag: Option, /// Pages whose template strip was dismissed this run. @@ -437,6 +545,8 @@ struct State { fonts: Vec, /// Fonts picked from the font box this session, latest first. recent_fonts: Vec, + /// What the toolbar's buttons apply from their menus' last picks. + toolbar: settings::Toolbar, /// The application's icon at the display's scale, for the title bar. app_icon: Option, commands: Vec, @@ -486,7 +596,6 @@ impl State { proxy: EventLoopProxy, input: Input, substitutes: &[PathBuf], - interface_font: Vec>, visible: bool, settings::Launch { file: settings, @@ -510,10 +619,14 @@ impl State { Input::Section { title, .. } => title.clone(), Input::Notebooks { .. } => "Snowbound".into(), }) - .with_inner_size(LogicalSize::new(1180.0, 760.0)), + .with_inner_size(LogicalSize::new(1180.0, 760.0)) + // The page list and a page beside it, under the toolbar's text group. + .with_min_inner_size(LogicalSize::new(600.0, 400.0)), )?, ); platform::install_text_input(&window); + // Beneath the surface's layer, which is added over it. + let backdrop = visible && platform::install_backdrop(&window); platform::install_menu(); let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); @@ -531,9 +644,17 @@ impl State { .await?; let (device, queue) = adapter.request_device(&Default::default()).await?; let size = window.inner_size(); - let config = surface + let mut config = surface .get_default_config(&adapter, size.width, size.height) .ok_or("No supported canvas surface")?; + let translucent = backdrop.then(|| { + // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which + // composites colour premultiplied in sRGB, as `Translucent` leaves it. + config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; + let window_format = config.format.remove_srgb_suffix(); + config.view_formats.push(window_format); + draw::Translucent::new(&device, config.format, window_format) + }); surface.configure(&device, &config); let renderer = Renderer::new(device, queue, config.format); let mut engine = TextEngine::default(); @@ -546,6 +667,7 @@ impl State { let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); let mut notebooks = Vec::new(); let mut session = None; + let mut sectionless = None; let (editor, scene) = match input { Input::Notes { document, @@ -605,6 +727,11 @@ impl State { (editor, Some((scene, [0.0; 2]))) } None => { + sectionless = notebooks + .iter() + .find(|library| Some(&library.location) == current.as_ref()) + .or(notebooks.first()) + .cloned(); let blank = TextDocument::new(vec![Paragraph::new( String::new(), Format::default(), @@ -629,22 +756,17 @@ impl State { window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); window.set_theme(stored.color_scheme.theme()); + let appearance = stored + .color_scheme + .theme() + .unwrap_or_else(|| platform::appearance(&window)); let mut ui = Ui::new( - theme( - stored - .color_scheme - .theme() - .unwrap_or_else(|| platform::appearance(&window)), - stored.light_pages, - ), + theme(appearance, stored.light_pages, backdrop), platform::double_click_interval(), ); - if !interface_font.is_empty() { - let family = ui - .use_fonts(interface_font) - .ok_or("The interface font files hold no font")?; - eprintln!("Using {family} for the interface"); - } + let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); + // A window shown but never focused hears no focus event; a hidden one draws as focused. + ui.window_focused = !visible || window.has_focus(); ui.set_focus(Some(page())); for family in FONTS { for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { @@ -692,10 +814,15 @@ impl State { dismissed: HashSet::new(), title_focus: None, menu: None, + link: None, + text_menu: None, options: None, color_scheme: stored.color_scheme, light_pages: stored.light_pages, + translucent, + titlebar, renaming: None, + sectionless, drag: None, templates: templates::View::Strip, thumbnails: templates::Thumbnails::default(), @@ -703,6 +830,7 @@ impl State { pages_open: true, fonts, recent_fonts: stored.recent_fonts, + toolbar: stored.toolbar, app_icon: platform::app_icon((16.0 * dpr).round() as u32), commands: Vec::new(), changed: false, @@ -725,6 +853,7 @@ impl State { opening: None, places: HashMap::new(), last_pages: HashMap::new(), + trail: navigation::Trail::default(), access_adapter, accessibility: accessibility::Accessibility::default(), }; @@ -741,9 +870,14 @@ impl State { } lap("open", start); let size = self.window.inner_size(); - let scale = self.window.scale_factor() as f32; + let scale = self.scale(); + if scale != self.ui.scale() { + self.renderer.clear_glyph_cache(); + let response = self.view.scale_factor_changed(scale)?; + self.respond(response); + } self.layout( - [size.width as f32 / scale, size.height as f32 / scale], + [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32), scale, )?; lap("build", start); @@ -793,6 +927,8 @@ impl State { self.ui.begin(size, scale, Instant::now()); let (section, open_tab, open_page) = self.build()?; self.options_dialog(); + self.link_dialog()?; + self.text_menu()?; self.ui.end(); self.edges(section, open_tab, open_page); if let Some(rect) = self.ui.rect(page()) { @@ -808,6 +944,22 @@ impl State { Ok(()) } + /// Takes `appearance`'s colours. + fn set_appearance(&mut self, appearance: winit::window::Theme) { + self.ui.theme = theme(appearance, self.light_pages, self.translucent.is_some()); + self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]); + } + + /// Takes the colour scheme chosen, or the system's where it follows the system. + fn follow_color_scheme(&mut self) { + self.window.set_theme(self.color_scheme.theme()); + let appearance = self + .color_scheme + .theme() + .unwrap_or_else(|| platform::appearance(&self.window)); + self.set_appearance(appearance); + } + /// Builds the frame's boxes and returns the section's colours as they ease, the open /// section tab and the open page's tab, for the edges drawn once they are laid out. fn build(&mut self) -> Result<(ui::Section, Id, Option), Box> { @@ -816,6 +968,11 @@ impl State { .as_ref() .and_then(|session| session.tabs[session.tab].color), )); + self.ui.icon_palette = self.ui.theme.icon_palette( + target.accent, + self.toolbar.highlight.map_or([1.0; 4], colorref), + ); + let strip = self.titlebar[usize::from(!self.ui.window_focused)]; let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] { std::array::from_fn(|channel| { self.ui @@ -831,6 +988,7 @@ impl State { edge: ease("edge", target.edge), accent: ease("accent", target.accent), }; + self.ui.theme.strip = ease("strip", strip); self.ui.theme.accent = section.accent; // Elsewhere the theme holds the platform's colours. #[cfg(target_os = "macos")] @@ -847,7 +1005,8 @@ impl State { if !platform::system_titlebar(&self.window) { self.title_bar(&theme); } - if self.session.is_none() && !self.temporary { + platform::update_menu(|| self.statuses()); + if self.session.is_none() && !self.temporary && self.sectionless.is_none() { self.welcome(&theme); return Ok((section, Id::ROOT, None)); } @@ -859,7 +1018,7 @@ impl State { ..Spec::default() }, ); - self.sidebar(&theme); + let sidebar = self.sidebar(&theme); self.ui.open( "main", Spec { @@ -877,26 +1036,40 @@ impl State { ..Spec::default() }, ); + self.sidebar_button(&theme, sidebar); self.ui.leaf( "corner", Spec { - size: [px(ROUNDING), px(1.0)], + size: [px(self.rounding()), px(1.0)], ..Spec::default() }, ); let row = self.ui.id("sections"); let (clicked, open_tab) = match &self.session { Some(session) => { + // A tab being renamed takes the name typed, which its field covers. let tabs: Vec<_> = session .tabs .iter() - .map(|tab| (tab.name.as_str(), section_color(tab.color))) + .map(|tab| { + let name = match &self.renaming { + Some(renaming) if renaming.entry(&session.library, &tab.path, true) => { + renaming.name.as_str() + } + _ => tab.name.as_str(), + }; + (name, section_color(tab.color)) + }) .collect(); let lit = self.page_drop(row); + let dragged = self.dragged_tab(row); let ui::shell::Tabs { clicked, context, held, + renamed, + slot, + settled, open: open_tab, } = ui::shell::section_tabs( &mut self.ui, @@ -904,16 +1077,19 @@ impl State { &tabs, session.tab, lit, + dragged, §ion, TAB_ROW, ); - let bar = self.ui.rect(tab_row).unwrap_or_default(); - self.drag_tabs(held, row, bar, section.accent); + self.drag_tabs(held, slot, settled, row); let Some(session) = &self.session else { unreachable!("The tabs are the session's") }; let open = |tab: usize| { - Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone()) + Command::OpenSection( + Arc::clone(&session.library), + session.tabs[tab].path.clone(), + ) }; if let Some((tab, point)) = context { self.menu = Some(( @@ -925,7 +1101,18 @@ impl State { )); self.ui.open_popup(menus::id()); } - (clicked.map(open), open_tab) + // Letting go of a dragged tab isn't a click on it. + let clicked = clicked.filter(|_| !self.dragged()).map(open); + if let Some(tab) = renamed { + let target = rename::Target::Entry { + library: Arc::clone(&session.library), + path: session.tabs[tab].path.clone(), + in_tab: true, + }; + self.rename(target); + } + self.tab_rename_field(&theme, row, tab_row); + (clicked, open_tab) } None => { let tabs = [("Temporary page", section_color(None))]; @@ -936,6 +1123,7 @@ impl State { shown, 0, None, + None, §ion, TAB_ROW, ) @@ -971,11 +1159,7 @@ impl State { && let Some(bar) = session.bar() { let changes = self.changes().len(); - let sections: Vec<&str> = session - .tabs - .iter() - .map(|tab| tab.name.as_str()) - .collect(); + let sections: Vec<&str> = session.tabs.iter().map(|tab| tab.name.as_str()).collect(); let steps = [ session.change.is_some_and(|at| at > 0), session.change.map_or(changes > 0, |at| at + 1 < changes), @@ -983,6 +1167,15 @@ impl State { self.commands .extend(conflict_bar(&mut self.ui, bar, §ions, steps)); } + if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) { + self.no_sections(&theme, library); + self.ui.close(); + self.ui.close(); + self.ui.close(); + self.ui.close(); + self.context_menu(); + return Ok((section, open_tab, None)); + } self.ui.open_as( page(), Spec { @@ -1023,12 +1216,14 @@ impl State { self.ui.close(); self.ui.close(); self.context_menu(); - if self.renaming.is_some() && self.ui.focused() != Some(sidebar::rename_field()) { - self.ui.set_focus(Some(sidebar::rename_field())); - } Ok((section, open_tab, open_page)) } + /// The page's corner radius, sharing its centres with the window's corners. + fn rounding(&self) -> f32 { + (platform::corner_radius(&self.window) - FRAME).max(0.0) + } + /// Borders the open section tab and the frame's top, and rounds and borders the page /// together with the open page's tab, joined where they meet. fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option) { @@ -1036,6 +1231,9 @@ impl State { let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else { return; }; + let rounding = self.rounding(); + // Where the open page tab meets the page, concentric with its neighbours. + let join = PILL_MARGIN + rounding; let [left, top, right, bottom] = page; let tab = open_page .and_then(|id| self.ui.rect(id)) @@ -1047,14 +1245,14 @@ impl State { row[3].min(panel[3]).min(bottom), ]; let reach = row[2].min(panel[2]); - (reach - right > 2.0 * JOIN && end - start > 2.0 * ROUNDING).then(|| { + (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| { // A tab too near the page's corner joins it along the edge. - let start = if start < top + ROUNDING + JOIN { + let start = if start < top + rounding + join { top } else { start }; - let end = if end > bottom - ROUNDING - JOIN { + let end = if end > bottom - rounding - join { bottom } else { end @@ -1062,59 +1260,75 @@ impl State { [start, end, reach] }) }); - let mut outline = vec![([left, top], ROUNDING)]; + let mut outline = vec![([left, top], rounding)]; match tab { Some([start, end, reach]) => { if start > top { - outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]); + outline.extend([([right, top], rounding), ([right, start], join)]); } - outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]); + outline.extend([([reach, start], rounding), ([reach, end], rounding)]); if end < bottom { - outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]); + outline.extend([([right, end], join), ([right, bottom], rounding)]); } } - None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]), + None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]), } - outline.push(([left, bottom], ROUNDING)); + outline.push(([left, bottom], rounding)); let height = (frame[3] - frame[1]).max(1.0); let paper = self.paper().color; self.ui.round_corners(&outline, paper, |y| { ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height) }); self.ui.border(&outline, true, section.edge); - // The frame's top corners share the page's centres; its top edge breaks where the - // open tab stands on it. + // The frame's top corners share the page's centres, and beside the sidebar so does + // its bottom one, rounding in as the sidebar opens; the frame's top edge breaks where + // the open tab stands on it. let [start, end] = [frame[0], frame[2]]; - let outer = platform::CORNER_RADIUS; + let outer = platform::corner_radius(&self.window); + let beside = outer * start / sidebar::WIDTH; let strip = self.ui.theme.strip; - self.ui.round_corners( - &[ - ([start, frame[1]], outer), - ([end, frame[1]], outer), - ([end, frame[3]], 0.0), - ([start, frame[3]], 0.0), - ], - paper, - |_| strip, - ); + // The border runs half its width inside the frame, on whole device pixels at any + // scale, its corners concentric with the fill's. The fill is cut at the border's + // outer edge, then again at its middle, so none shows through its antialiased rim. + let [west, north, east] = [start + 0.5, frame[1] + 0.5, end - 0.5]; + let inner = |radius: f32| (radius - 0.5).max(0.0); + for [left, top, right, cut] in [[start, frame[1], end, 0.0], [west, north, east, 0.5]] { + self.ui.round_corners( + &[ + ([left, top], (outer - cut).max(0.0)), + ([right, top], (outer - cut).max(0.0)), + ([right, frame[3]], 0.0), + ([left, frame[3]], (beside - cut).max(0.0)), + ], + paper, + |_| strip, + ); + } let [foot, toe] = self .ui .rect(open_tab) .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)); - for edge in [ - [ - ([start, frame[1] + outer], 0.0), - ([start, frame[1]], outer), - ([foot, frame[1]], 0.0), + // The border runs down both sides to the window's bottom, round the bottom corner + // beside the sidebar. + self.ui.border( + &[ + ([west + beside, frame[3]], 0.0), + ([west, frame[3]], inner(beside)), + ([west, north], inner(outer)), + ([foot, north], 0.0), ], - [ - ([toe, frame[1]], 0.0), - ([end, frame[1]], outer), - ([end, frame[1] + outer], 0.0), + false, + section.edge, + ); + self.ui.border( + &[ + ([toe, north], 0.0), + ([east, north], inner(outer)), + ([east, frame[3]], 0.0), ], - ] { - self.ui.border(&edge, false, section.edge); - } + false, + section.edge, + ); } /// The window's title bar beside the traffic lights: the application's icon, the @@ -1136,19 +1350,22 @@ impl State { ..Spec::default() }, ); - self.ui.leaf( - "notebook", - Spec { - size: [fit(), px(TITLE)], - image: self.app_icon.as_ref(), - text: Some(match &self.session { - Some(session) => &session.library.name, - None if self.temporary => "Temporary page", - None => "Snowbound", - }), - ..Spec::default() - }, - ); + // AppKit centres the window's title over the strip. + if !cfg!(target_os = "macos") { + self.ui.leaf( + "notebook", + Spec { + size: [fit(), px(TITLE)], + image: self.app_icon.as_ref(), + text: Some(match &self.session { + Some(session) => &session.library.name, + None if self.temporary => "Temporary page", + None => "Snowbound", + }), + ..Spec::default() + }, + ); + } self.ui.leaf( "space", Spec { @@ -1175,23 +1392,15 @@ impl State { /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home /// ribbon groups them. fn toolbar(&mut self, theme: &Theme) -> Result<(), Box> { - use canvas::editor::{Alignment, FormatState, Formatting, NoteTag, Toggle}; + use canvas::editor::{ + Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, + }; + use commands::{Choice, Id as Cmd, shortcut}; // A focused picture has no text to show a format for. - let state = self - .view - .editor - .format_state() - .unwrap_or_else(|_| FormatState { - toggles: Vec::new(), - font: None, - font_size: None, - alignment: None, - bullets: false, - numbering: false, - tags: Vec::new(), - }); + let state = self.format_state(); let fonts = &self.fonts; let recent_fonts = &self.recent_fonts; + let pens = &self.toolbar; let engine = &self.view.engine; // Under the system's title bar the save status joins the toolbar. let status = platform::system_titlebar(&self.window) @@ -1200,8 +1409,7 @@ impl State { let ui = &mut self.ui; let text = theme.text; let on = |toggle| state.toggles.contains(&toggle); - let mut command = None; - let mut history = None; + let mut choice = None; ui.open( "toolbar", Spec { @@ -1214,10 +1422,10 @@ impl State { ); group(ui, "history", |ui| { if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked { - history = Some(true); + choice = Some(Choice::Command(Cmd::Undo)); } if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked { - history = Some(false); + choice = Some(Choice::Command(Cmd::Redo)); } }); divider(ui, "history", theme); @@ -1272,7 +1480,7 @@ impl State { ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) && let Some(name) = choices[index].1 { - command = Some(Formatting::Font(name.to_owned())); + choice = Some(Choice::Font(name.to_owned())); } let combo = ui.id("size"); if ui::shell::combo(ui, "size", &size, 44.0).pressed { @@ -1300,20 +1508,113 @@ impl State { let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) { - command = Some(Formatting::FontSize(sizes[index])); + choice = Some(Choice::Size(sizes[index])); } - let bullets = - ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); - let numbering = - ui::shell::split_button(ui, "numbering", art::NUMBERING, None, state.numbering); - if bullets.iter().any(|signal| signal.clicked) { - command = Some(Formatting::Bullets); + // The buttons apply OneNote's default bullet and number, as its own do. + let [bullets, numbering] = [ui.id("bullets"), ui.id("numbering")]; + if ui::shell::split_button( + ui, + "bullets", + art::BULLETS, + None, + state.bullets, + popup("bullets"), + ) + .clicked + { + choice = Some(Choice::Command(Cmd::Bullets)); } - if numbering.iter().any(|signal| signal.clicked) { - command = Some(Formatting::Numbering); + if ui::shell::split_button( + ui, + "numbering", + art::NUMBERING, + None, + state.numbering, + popup("numbering"), + ) + .clicked + { + choice = Some(Choice::Command(Cmd::Numbering)); + } + let current = |bullet| match state.list { + Some(ListStyle::Bullet(place)) if bullet => Some(place), + Some(ListStyle::Number(place)) if !bullet => Some(place), + _ => None, + }; + let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default()); + if let Some(place) = list_gallery( + ui, + popup("bullets"), + anchor, + ["Recently Used Bullets", "Bullet Library"], + (&pens.bullets, BULLET_LIBRARY.len()), + current(true), + [36.0, 36.0], + |ui, place| { + let (font, glyph, _) = BULLET_LIBRARY[place]; + ui.leaf( + "glyph", + Spec { + size: [fill(), fill()], + text: Some(&canvas::outline::symbol_text(font, glyph)), + font: Some(font), + font_size: Some(18.0), + center: true, + ..Spec::default() + }, + ); + }, + ) { + choice = Some(Choice::List(place.map(ListStyle::Bullet))); + } + let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default()); + if let Some(place) = list_gallery( + ui, + popup("numbering"), + anchor, + ["Recently Used Number Formats", "Numbering Library"], + (&pens.numbering, NUMBER_LIBRARY.len()), + current(false), + [72.0, 3.0 * NUMBER_LINE + 8.0], + |ui, place| { + for number in 1..=3 { + let label = canvas::outline::numbered(NUMBER_LIBRARY[place], number) + .unwrap_or_default(); + ui.open( + ("line", number), + Spec { + size: [fill(), px(NUMBER_LINE)], + gap: 3.0, + ..Spec::default() + }, + ); + ui.leaf( + "number", + Spec { + size: [fit(), fill()], + text: Some(&label), + font_size: Some(11.0), + ..Spec::default() + }, + ); + // A line of text after the number, as OneNote's previews draw it. + ui.leaf( + "text", + Spec { + size: [fill(), fill()], + fill: Some(theme.text_dim), + inset: [0.0, NUMBER_LINE / 2.0, 2.0, NUMBER_LINE / 2.0 - 1.0], + ..Spec::default() + }, + ); + ui.close(); + } + }, + ) { + choice = Some(Choice::List(place.map(ListStyle::Number))); } if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked { - command = Some(Formatting::Clear); + choice = Some(Choice::Command(Cmd::ClearFormatting)); } }); row(ui, 1, |ui| { @@ -1324,12 +1625,12 @@ impl State { ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), ] { if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked { - command = Some(Formatting::Toggle(toggle)); + choice = Some(Choice::Command(Cmd::Toggle(toggle))); } } let script = ui.id("script"); let superscript = on(Toggle::Superscript); - let [button, menu] = ui::shell::split_button( + if ui::shell::split_button( ui, "script", if superscript { @@ -1339,47 +1640,46 @@ impl State { }, None, on(Toggle::Subscript) || superscript, - ); - if button.clicked { - command = Some(Formatting::Toggle(if superscript { + popup("script"), + ) + .clicked + { + choice = Some(Choice::Command(Cmd::Toggle(if superscript { Toggle::Superscript } else { Toggle::Subscript - })); + }))); } - if menu.pressed { - ui.open_popup(popup("script")); - } - let items = [ - ui::popup::Item { - text: "Subscript", - icon: Some(art::SUBSCRIPT), - shortcut: "⌘=", - checked: on(Toggle::Subscript), - ..Default::default() - }, - ui::popup::Item { - text: "Superscript", - icon: Some(art::SUPERSCRIPT), - shortcut: "⇧⌘=", - checked: superscript, - ..Default::default() - }, + let scripts = [ + (Toggle::Subscript, art::SUBSCRIPT), + (Toggle::Superscript, art::SUPERSCRIPT), ]; + let keys = scripts.map(|(toggle, _)| shortcut(Cmd::Toggle(toggle))); + let items: Vec<_> = scripts + .iter() + .zip(&keys) + .map(|((toggle, icon), key)| ui::popup::Item { + text: commands::command(Cmd::Toggle(*toggle)).title, + icon: Some(icon), + shortcut: key, + checked: on(*toggle), + ..Default::default() + }) + .collect(); let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) { - command = Some(Formatting::Toggle( - [Toggle::Subscript, Toggle::Superscript][index], - )); + choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0))); } - for (part, icon, swatches, columns, none, default) in [ + // Each button applies its menu's last pick: the highlighter shows it in its + // artwork, the font colour in a bar. + for (part, icon, swatches, columns, none, bar) in [ ( "highlight", art::HIGHLIGHTER, - HIGHLIGHTS, + &HIGHLIGHTS.map(|(color, _)| color)[..], 5, - "No colour", - 0x00ffff, + "No Color", + None, ), ( "color", @@ -1387,68 +1687,69 @@ impl State { &FONT_COLORS[..], 10, "Automatic", - 0x3a3ae8, + Some(pens.font_color.map_or(text, colorref)), ), ] { let split = ui.id(part); - let [button, menu] = - ui::shell::split_button(ui, part, icon, Some(colorref(default)), false); - let paint = |color| { - if part == "highlight" { - Formatting::Highlight(color) - } else { - Formatting::Color(color) - } - }; + let button = ui::shell::split_button(ui, part, icon, bar, false, popup(part)); + let highlight = part == "highlight"; if button.clicked { - command = Some(paint(Some(default))); - } - if menu.pressed { - ui.open_popup(popup(part)); + choice = Some(Choice::Command(if highlight { + Cmd::Highlight + } else { + Cmd::FontColor + })); } let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); if let Some(chosen) = ui::popup::colors(ui, popup(part), anchor, none, &colors, columns) { - command = Some(paint(chosen.and_then(|chosen| { + let color = chosen.and_then(|chosen| { swatches .iter() .zip(&colors) .find(|(_, color)| **color == chosen) .map(|(stored, _)| *stored) - }))); + }); + choice = Some(if highlight { + Choice::Highlight(color) + } else { + Choice::Color(color) + }); } } if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked { - command = Some(Formatting::Outdent); + choice = Some(Choice::Command(Cmd::Outdent)); } if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked { - command = Some(Formatting::Indent); + choice = Some(Choice::Command(Cmd::Indent)); } - let align = ui.id("align"); let alignments = [ - (Alignment::Left, "Align left", art::ALIGN_LEFT), - (Alignment::Center, "Centre", art::ALIGN_CENTER), - (Alignment::Right, "Align right", art::ALIGN_RIGHT), + (Alignment::Left, art::ALIGN_LEFT), + (Alignment::Center, art::ALIGN_CENTER), + (Alignment::Right, art::ALIGN_RIGHT), ]; + let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment))); let current = alignments .iter() - .find(|(alignment, ..)| Some(*alignment) == state.alignment) + .find(|(alignment, _)| Some(*alignment) == state.alignment) .unwrap_or(&alignments[0]); - let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); - if button.pressed || menu.pressed { - ui.open_popup(popup("align")); - } - let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { - text: name, - icon: Some(icon), - checked: Some(alignment) == state.alignment, - ..Default::default() - }); - let anchor = ui::Anchor::Below(ui.rect(align).unwrap_or_default()); + let anchor = ui::shell::menu_button(ui, "align", current.1, popup("align")); + // The current alignment starts highlighted, keeping its icon under the button's. + let items: Vec<_> = alignments + .iter() + .zip(&keys) + .map(|((alignment, icon), key)| ui::popup::Item { + text: commands::command(Cmd::Align(*alignment)).title, + icon: Some(icon), + shortcut: key, + current: Some(*alignment) == state.alignment, + ..Default::default() + }) + .collect(); if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) { - command = Some(Formatting::Align(alignments[index].0)); + choice = Some(Choice::Command(Cmd::Align(alignments[index].0))); } }); }); @@ -1467,42 +1768,86 @@ impl State { (tag_sources(TagIcon::Address), NoteTag::Address), (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber), ]; + let all_tags = &tags; group(ui, "tags", |ui| { for (index, tags) in tags.chunks(5).enumerate() { row(ui, index, |ui| { for (column, (icon, tag)) in tags.iter().enumerate() { let lit = state.tags.contains(tag); if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked { - command = Some(Formatting::Tag(*tag)); + choice = Some(Choice::Command(Cmd::Tag(*tag))); } } if index == 1 { - ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, false); + let more = ui.id("more"); + let open = ui.popup_open(popup("tags")); + if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open) + .pressed + { + ui.open_popup(popup("tags")); + } + let shortcuts: Vec<_> = all_tags + .iter() + .map(|(_, tag)| shortcut(Cmd::Tag(*tag))) + .collect(); + let remove = shortcut(Cmd::RemoveTags); + let mut items: Vec<_> = all_tags + .iter() + .zip(&shortcuts) + .map(|((icon, tag), shortcut)| ui::popup::Item { + text: commands::command(Cmd::Tag(*tag)).title, + icon: Some(*icon), + colored: true, + shortcut, + ..Default::default() + }) + .collect(); + items.push(ui::popup::Item { + text: commands::command(Cmd::RemoveTags).title, + shortcut: &remove, + separated: true, + ..Default::default() + }); + let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); + if let Some(chosen) = + ui::popup::menu(ui, popup("tags"), anchor, &items, None) + { + choice = Some(Choice::Command( + all_tags + .get(chosen) + .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)), + )); + } } }); } }); divider(ui, "tags", theme); group(ui, "insert", |ui| { - row(ui, 0, |ui| { - for (part, icon) in [ - ("table", art::TABLE), - ("picture", art::PICTURE), - ("file", art::ATTACHMENT), - ("link", art::LINK), - ] { - ui::shell::tool_button(ui, part, icon, text, false); - } - }); - row(ui, 1, |ui| { - for (part, icon) in [ - ("date", art::CALENDAR), - ("time", art::CLOCK), - ("equation", art::EQUATION), - ] { - ui::shell::tool_button(ui, part, icon, text, false); - } - }); + for (index, buttons) in [ + &[ + ("table", art::TABLE, Cmd::Table), + ("picture", art::PICTURE, Cmd::Picture), + ("file", art::ATTACHMENT, Cmd::Attachment), + ("link", art::LINK, Cmd::Link), + ][..], + &[ + ("date", art::CALENDAR, Cmd::Date), + ("time", art::CLOCK, Cmd::Time), + ("equation", art::EQUATION, Cmd::Equation), + ], + ] + .into_iter() + .enumerate() + { + row(ui, index, |ui| { + for (part, icon, id) in buttons { + if ui::shell::tool_button(ui, part, icon, text, false).clicked { + choice = Some(Choice::Command(*id)); + } + } + }); + } }); ui.leaf( "space", @@ -1525,11 +1870,10 @@ impl State { } let zoom = self.view.zoom(); let label = format!("{:.0}%", zoom * 100.0); - let mut chosen = None; group(ui, "zoom", |ui| { row(ui, 0, |ui| { if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked { - chosen = Some(zoom / 1.1); + choice = Some(Choice::Command(Cmd::ZoomOut)); } let level = ui.leaf( "level", @@ -1544,36 +1888,16 @@ impl State { }, ); if level.clicked { - chosen = Some(1.0); + choice = Some(Choice::Command(Cmd::ActualSize)); } if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked { - chosen = Some(zoom * 1.1); + choice = Some(Choice::Command(Cmd::ZoomIn)); } }); }); ui.close(); - if let Some(zoom) = chosen { - let response = self.view.set_zoom(zoom)?; - self.respond(response); - } - if let Some(command) = command { - if let Formatting::Font(name) = &command { - self.recent_fonts.retain(|recent| recent != name); - self.recent_fonts.insert(0, name.clone()); - self.recent_fonts.truncate(RECENT_FONTS); - self.save_settings(); - } - let response = self.view.format(command)?; - self.respond(response); - } - if let Some(undo) = history { - let view = &mut self.view; - let done = if undo { - view.editor.undo(&mut view.engine)? - } else { - view.editor.redo(&mut view.engine)? - }; - self.changed |= done; + if let Some(choice) = choice { + self.choose(choice); } Ok(()) } @@ -1626,24 +1950,43 @@ impl State { let found = self .search .found_in(&session.library.key(&session.tabs[session.tab].path)); - let rows = page_rows(&mut self.ui, theme, section, session, &found); - self.commands.extend(rows.clicked.map(Command::OpenPage)); + let rounding = self.rounding(); + let dragged = self.dragged_page(); + let rows = page_rows( + &mut self.ui, + theme, + section, + session, + &found, + rounding, + self.renaming.as_mut(), + dragged, + ); + self.commands.extend( + rows.version + .map(|(page, version)| Command::OpenVersion { page, version }), + ); if let Some((space, point)) = rows.context { self.menu = Some((menus::Target::Page(space), point)); self.ui.open_popup(menus::id()); } - let rect = self.ui.rect(panel).unwrap_or_default(); - self.drag_pages(rows.held, section, rect, tabs); + if let Some(space) = rows.renamed { + self.rename(rename::Target::Page(space)); + } + if let Some(keep) = rows.kept { + self.finish_renaming(keep); + } + self.drag_pages(rows.held, &rows.places, rows.settled, tabs); + if !self.dragged() { + self.commands.extend(rows.clicked.map(Command::OpenPage)); + } self.ui.close(); rows.open } /// Hands the page the events routed to its box, in its device pixels. fn page_events(&mut self, events: Vec) -> Result<(), Box> { - let read_only = self - .session - .as_ref() - .is_some_and(|session| session.conflict(session.space).is_some()); + let read_only = self.session.as_ref().is_some_and(Session::read_only); let scale = self.ui.scale(); let corner = self.ui.rect(page()).unwrap_or_default(); let device = |point: [f32; 2]| { @@ -1667,6 +2010,14 @@ impl State { self.view.pointer_released()? } } + ui::Event::Button { + button: MouseButton::Right, + pressed: true, + .. + } if !read_only => { + self.open_text_menu()?; + continue; + } ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue, // A conflict page takes no typing; commands and caret moves still apply. ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue, @@ -1691,28 +2042,6 @@ impl State { } ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?, ui::Event::Key { key, text } => { - let modifiers = self.view.modifiers(); - // OneNote's Find on This Page is Ctrl+F, its search Ctrl+E. - if modifiers.command && !modifiers.shift && self.session.is_some() { - match &key { - Key::Character(character) if character.eq_ignore_ascii_case("f") => { - self.start_find(false)?; - continue; - } - Key::Character(character) if character.eq_ignore_ascii_case("e") => { - self.start_search(); - continue; - } - _ => {} - } - } - // OneNote's New Page is Ctrl+N. - let new = matches!(&key, Key::Character(character) - if character.eq_ignore_ascii_case("n")); - if modifiers.command && !modifiers.shift && new && self.session.is_some() { - self.commands.push(Command::NewPage { under: None }); - continue; - } self.view.key(&ui::edit_key(&key), text.as_deref())? } ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?, @@ -1774,7 +2103,12 @@ impl State { } Command::CopyVersion { version, tab } => { let session = self.session.as_ref().ok_or("No section is open")?; - let page = session.section.page(version)?; + let page = match session.version { + Some(open) if open == version => { + session.section.version(session.space, open)? + } + _ => session.section.page(version)?, + }; if tab == session.tab { session.section.import_page(&page, &self.author)?; } else { @@ -1793,6 +2127,11 @@ impl State { } } Command::SelectChange { forward } => self.select_change(forward)?, + Command::History { page, show } => self.show_history(page, show)?, + Command::OpenVersion { page, version } => self.open_version(page, version)?, + Command::RestoreVersion { page, version } => self.restore_version(page, version)?, + Command::DeletePageVersion { page, version } => self.delete_version(page, version)?, + Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?, Command::OpenNotebook => { if let Some(path) = platform::pick_notebook("Open Notebook") { self.open_path(&path); @@ -1824,17 +2163,15 @@ impl State { let response = self.view.paste(&text, language)?; self.respond(response); } - Command::Page(Request::CharacterPalette) => platform::show_character_palette(), + Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, + Command::Choose(choice) => self.run(choice)?, } Ok(()) } /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded` /// shows it unless a newer read was asked for meanwhile. - fn load( - &mut self, - read: impl FnOnce() -> Result> + Send + 'static, - ) { + fn load(&mut self, read: impl FnOnce() -> Result> + Send + 'static) { self.loading += 1; let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); let layouts = Arc::clone(&self.layouts); @@ -1842,7 +2179,9 @@ impl State { let laid = read().and_then(|loaded| { let (shown, page) = match loaded { Loaded::Section(session, page) => (Shown::Section(session), page), + Loaded::Created(session, page) => (Shown::Created(session), page), Loaded::Page(space, page) => (Shown::Page(space), page), + Loaded::Version(space, version, page) => (Shown::Version(space, version), page), Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), }; let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; @@ -1864,12 +2203,11 @@ impl State { fn open_loaded(&mut self) -> Result<(), Box> { let reads: Vec = self.loads.1.try_iter().collect(); for (id, loaded) in reads { - if id != self.loading { - continue; - } match loaded { + // A later load supersedes what this one shows, but not its failure. + Ok(_) if id != self.loading => {} Ok(Laid::Page(opening)) => self.opening = Some(*opening), - Ok(Laid::Library(library, path)) => self.adopt(library, &path)?, + Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?, Err(error) => { eprintln!("{error}"); platform::alert("Couldn't open", &error); @@ -1896,11 +2234,13 @@ impl State { self.persist()?; if let Some(session) = &self.session { let key = session.library.key(&session.tabs[session.tab].path); - self.places.insert((key.clone(), session.space), self.view.place()); + self.places + .insert((key.clone(), session.space), self.view.place()); self.last_pages.insert(key, session.space); } + let created = matches!(opening.loaded, Shown::Created(_)); match opening.loaded { - Shown::Section(session) => { + Shown::Section(session) | Shown::Created(session) => { // The settings keep the notebook shown, not the section. let other_notebook = self .session @@ -1916,13 +2256,18 @@ impl State { None => self.notebooks.push(Arc::clone(&session.library)), } self.session = Some(*session); + self.sectionless = None; if other_notebook { self.save_settings(); } } - Shown::Page(space) => { + Shown::Page(space) | Shown::Version(space, _) => { let session = self.session.as_mut().ok_or("No section is open")?; session.space = space; + session.version = match opening.loaded { + Shown::Version(_, version) => Some(version), + _ => None, + }; session.change = None; // Pages created, moved or deleted since the list was read. session.pages = session.section.pages()?; @@ -1933,24 +2278,39 @@ impl State { self.places.get(&(key, session.space)).copied() }); self.view.open(opening.editor, Some(opening.scene), place); + self.visited(); self.opened()?; self.refind(true)?; if self.title_focus.take().is_some_and(|space| { self.session .as_ref() .is_some_and(|session| session.space == space) - }) && let Some(title) = self.view.editor.outlines().iter().find(|outline| outline.title) + }) && let Some(title) = self + .view + .editor + .outlines() + .iter() + .find(|outline| outline.title) { self.view.editor.focus_outline(title.id)?; let response = self.view.focus_text()?; self.respond(response); } + if created && let Some(session) = &self.session { + let target = rename::Target::Entry { + library: Arc::clone(&session.library), + path: session.tabs[session.tab].path.clone(), + in_tab: true, + }; + self.rename(target); + } Ok(()) } /// Takes `library`, a notebook read again after a change, in place of the one it was; /// the open section, now at catalog `path` in it, stays open, reopened where it moved. - fn adopt(&mut self, library: Arc, path: &str) -> Result<(), Box> { + /// Without `path` the notebook has no sections left, and one of its shows none. + fn adopt(&mut self, library: Arc, path: Option<&str>) -> Result<(), Box> { match self .notebooks .iter_mut() @@ -1959,6 +2319,23 @@ impl State { Some(open) => *open = Arc::clone(&library), None => self.notebooks.push(Arc::clone(&library)), } + let shown = |shown: &Library| shown.location == library.location; + let Some(path) = path else { + if self.sectionless.as_deref().is_some_and(shown) + || self + .session + .as_ref() + .is_some_and(|session| shown(&session.library)) + { + self.persist()?; + if let Some(session) = self.session.take() { + session.section.close()?; + } + self.sectionless = Some(library); + self.title(); + } + return Ok(()); + }; let Some(session) = &mut self.session else { return Ok(()); }; @@ -2018,8 +2395,8 @@ impl State { let corner = self.ui.rect(page()).unwrap_or_default(); let [x0, y0, x1, y1] = self.view.caret_area()?; self.window.set_ime_cursor_area( - PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale), - PhysicalSize::new(x1 - x0, y1 - y0), + LogicalPosition::new(x0 / scale + corner[0], y0 / scale + corner[1]), + LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale), ); let start = Instant::now(); self.update_accessibility()?; @@ -2066,6 +2443,10 @@ impl State { }; if self.window.title() != title { self.window.set_title(&title); + platform::represent( + &self.window, + self.session.as_ref().map(|session| session.section.file()), + ); } } @@ -2153,8 +2534,8 @@ impl State { if ops.is_empty() { return Ok(()); } - if session.conflict(session.space).is_some() { - // A conflict page is read-only: it returns to what is stored. + if session.read_only() { + // A conflict page or a version is read-only: it returns to what is stored. self.stale = true; return Ok(()); } @@ -2267,7 +2648,19 @@ impl State { let back = session.shown; session.pages = session.section.pages()?; session.refresh_conflicts()?; + session.refresh_history()?; let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space); + let gone = session.version.is_some_and(|version| { + !session + .page_versions(session.space) + .iter() + .any(|listed| listed.context == version) + }); + if gone && listed(session.space) { + // A version deleted elsewhere returns to its page. + self.commands.push(Command::OpenPage(session.space)); + return Ok(()); + } if !listed(session.space) && session.conflict(session.space).is_none() { // A page removed elsewhere, or a conflict page deleted elsewhere, which // returns to its page. @@ -2291,9 +2684,14 @@ impl State { let mut error = None; let view = &self.view; let corner = self.ui.rect(page()).unwrap_or_default(); - let mut viewport = view.viewport; - viewport.origin[0] += corner[0] * self.ui.scale(); - viewport.origin[1] += corner[1] * self.ui.scale(); + let scale = self.ui.scale(); + // Bounds are in the window's pixels, which `scale` renders fewer of when capped. + let ratio = self.window.scale_factor() as f32 / scale; + let viewport = canvas::gpu::Viewport { + size: view.viewport.size.map(|side| (side as f32 * ratio) as u32), + scale: view.viewport.scale * ratio, + origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio), + }; self.access_adapter.update_if_active(|| { match self.accessibility.update( &view.editor, @@ -2419,7 +2817,23 @@ impl State { } }; lap("acquire", start); - self.paint(&frame.texture.create_view(&Default::default()))?; + let size = [self.config.width, self.config.height]; + let target = self + .translucent + .as_mut() + .map(|translucent| translucent.target(&self.renderer.device, size)); + self.paint( + target + .as_ref() + .unwrap_or(&frame.texture.create_view(&Default::default())), + )?; + if let Some(translucent) = &self.translucent { + let window = frame.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(self.config.format.remove_srgb_suffix()), + ..Default::default() + }); + translucent.present(&self.renderer.device, &self.renderer.queue, &window); + } self.window.pre_present_notify(); let start = Instant::now(); self.renderer.queue.present(frame); @@ -2432,6 +2846,15 @@ impl State { Ok(()) } + /// The window's pixels per point, lowered where the window is larger than the GPU's + /// largest texture, so the surface renders scaled rather than failing. + fn scale(&self) -> f32 { + let size = self.window.inner_size(); + let most = self.renderer.device.limits().max_texture_dimension_2d as f32; + let longest = size.width.max(size.height) as f32; + self.window.scale_factor() as f32 * (most / longest).min(1.0) + } + /// The paper the open page lies on: the theme's, in the page's colour. fn paper(&self) -> canvas::gpu::Paper { canvas::gpu::Paper { @@ -2464,13 +2887,7 @@ impl State { let layers: Vec<_> = interface .iter() .map(|layer| match layer { - ui::Layer::Primitives { clip, primitives } => draw::Layer { - scale, - origin: [0.0; 2], - clip: clip.map(|clip| clip.map(|value| value * scale)), - backdrop: None, - primitives, - }, + ui::Layer::Primitives(primitives) => primitives.layer(scale), ui::Layer::Custom { rect, .. } => draw::Layer { scale: viewport.scale, origin: [ @@ -2479,6 +2896,7 @@ impl State { ], clip: Some(rect.map(|value| value * scale)), backdrop: Some(paper.color), + motion: None, primitives: &page_primitives, }, }) @@ -2489,7 +2907,7 @@ impl State { .draw( target, [self.config.width, self.config.height], - self.ui.theme.base, + self.ui.theme.strip, &layers, ) .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; @@ -2583,6 +3001,23 @@ impl State { if let ui::Event::Button { pressed: false, .. } = event { self.strip_held = false; } + // Escape calls a drag off: what it held slides back. + if let ui::Event::Key { + key: Key::Named(NamedKey::Escape), + .. + } = event + && let Some(drag) = self.drag.as_mut().filter(|drag| drag.live()) + { + drag.cancelled = true; + self.window.request_redraw(); + return; + } + if let ui::Event::Button { pressed: true, .. } = event + && self.renaming.is_some() + && self.ui.box_at(self.pointer) != Some(rename::field()) + { + self.finish_renaming(true); + } if let ui::Event::Button { button: MouseButton::Left, pressed: true, @@ -2613,15 +3048,12 @@ impl State { } return; } - // Settings' shortcut, which AppKit's menu takes first on macOS. - if let ui::Event::Key { - key: Key::Character(character), - .. - } = &event - && character == "," - && ui::edit_modifiers(self.ui.modifiers()).command + // On macOS the menu bar takes the chords of the items it enables first. + if let ui::Event::Key { key, .. } = &event + && let Some(id) = + commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) { - self.open_options(); + self.choose(commands::Choice::Command(id)); } else { self.ui.event(event); } @@ -2629,8 +3061,9 @@ impl State { } } -/// The interface's colours in `appearance`, on white pages when `light_pages`. -fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme { +/// The interface's colours in `appearance`, on white pages when `light_pages`, and over the +/// system's material where it shows through as a `backdrop`. +fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme { let mut theme = match appearance { winit::window::Theme::Dark => Theme::dark(), winit::window::Theme::Light => Theme::light(), @@ -2639,7 +3072,11 @@ fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme { let light = Theme::light(); [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; } - theme + if backdrop { + theme.over_backdrop() + } else { + theme + } } /// A section's colour as linear RGBA; sections without one take OneNote's default blue. @@ -2663,16 +3100,19 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O ..Spec::default() }, ); - ui.leaf( - "icon", - Spec { - size: [fit(), px(16.0)], - icon: Some(art::CONFLICT), - color: Some(draw::srgb(0xc0, 0x48, 0x20)), - ..Spec::default() - }, - ); - // OneNote 2010's words (corpus/conflict-page/native), a sentence to a line. + // OneNote marks conflicts, not versions. + if !matches!(bar, Bar::History { .. }) { + ui.leaf( + "icon", + Spec { + size: [fit(), px(16.0)], + icon: Some(art::CONFLICT), + color: Some(draw::srgb(0xc0, 0x48, 0x20)), + ..Spec::default() + }, + ); + } + // OneNote 2010's words (corpus/conflict-page/native, corpus/page-versions/native). let [said, action] = match bar { Bar::Page { shown, .. } => [ "This page has changes that could not be merged during synchronization.", @@ -2686,6 +3126,10 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.", "Click here for more options.", ], + Bar::History { .. } => [ + "This is an earlier version of the page. It will be deleted over time.", + "Click here to restore or delete this version.", + ], }; ui.open( "message", @@ -2699,8 +3143,9 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O ui.leaf( part, Spec { - size: [fit(), px(18.0)], + size: [fill(), fit()], text: Some(text), + overflow: ui::Overflow::Wrap, color: Some(draw::srgb(0x20, 0x20, 0x20)), ..Spec::default() }, @@ -2717,6 +3162,16 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O let anchor = ui::Anchor::Below([left, top, left, bottom]); match bar { Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }), + Bar::History { + page, + version, + grouped, + } => { + if clicked { + ui.open_popup(menu); + } + history::menu(ui, anchor, [page, version], grouped, sections) + } Bar::Version { page, version } => { if clicked { ui.open_popup(menu); @@ -2782,76 +3237,246 @@ struct Rows { context: Option<(ExGuid, [f32; 2])>, /// The page held down, which a drag moves. held: Option, + /// A page version clicked: the page, and the version's context. + version: Option<(ExGuid, ExGuid)>, + /// The page whose tab was pressed twice in a row, which renames it. + renamed: Option, + /// Whether the rename field kept or dropped the name typed, once it closes. + kept: Option, + /// Where each page's tab stands down the list with none dragged. + places: Vec, + /// The dragged tab, let go, stands in its place. + settled: bool, +} + +/// A page's tab being dragged: the page, the top it follows the pointer to down the list +/// until let go, and where among the other pages it would land. +#[derive(Clone, Copy)] +struct PageDrag { + space: ExGuid, + top: Option, + slot: Option, +} + +/// The page list's zero-height first box, where its rows are measured from. +fn page_list_top() -> &'static str { + "top" } /// The page list's rows, a page's conflict pages beneath it while shown and the pages a -/// search `found` marked. +/// search `found` marked. A `dragged` page's tab follows the pointer over the others, which +/// slide aside to open the gap it would land in; once let go it eases into its place. +#[allow(clippy::too_many_arguments)] fn page_rows( ui: &mut Ui, theme: &Theme, section: &ui::Section, session: &Session, found: &HashSet, + rounding: f32, + mut renaming: Option<&mut rename::Renaming>, + dragged: Option, ) -> Rows { - let mut rows = Rows::default(); - for (space, title, level) in &session.pages { - let versions = session.versions(*space); - let tab = PageTab { - label: if title.is_empty() { - "Untitled page" - } else { - title - }, - dim: title.is_empty(), - indent: level.saturating_sub(1), - conflicted: !versions.is_empty(), - found: found.contains(space), - }; - let selected = *space == session.space; - if selected { - rows.open = Some(ui.id(space)); - } - let signal = page_tab(ui, theme, section, space, &tab, selected); - if signal.clicked && !selected { - rows.clicked = Some(*space); - } - if let Some(point) = signal.context { - rows.context = Some((*space, point)); - } - if signal.dragging { - rows.held = Some(*space); - } - if session.shown != Some(*space) { + let mut rows = Rows { + settled: true, + ..Rows::default() + }; + ui.leaf(page_list_top(), Spec::default()); + let from = dragged.and_then(|dragged| { + session + .pages + .iter() + .position(|(space, ..)| *space == dragged.space) + }); + let slot = dragged.and_then(|dragged| dragged.slot).or(from); + // Down the list as laid out without the dragged tab, and as it stands with it. + let (mut laid, mut natural, mut gap) = (0.0, 0.0, None); + let mut others = 0; + let mut lifted = None; + for (index, (space, title, level)) in session.pages.iter().enumerate() { + rows.places.push(natural); + if from == Some(index) { + lifted = Some((space, title, level)); + natural += ROW; continue; } - // OneNote lists a page's conflict pages beneath it, each by its date and whose - // version it is. - let muted = ui::Section { - tab: ui::mix(section.tab, theme.chip, 0.6), - ..*section + if Some(others) == slot { + gap = Some(laid); + } + // Eased only while a tab is dragged: the list lays out anew once it ends. + let shift = match (from, slot) { + (Some(_), Some(slot)) => ui.animate( + ui.id((space, "slide")), + if others >= slot { ROW } else { 0.0 }, + ), + _ => 0.0, }; - for version in versions { - let label = match version.created { - Some(created) => format!("{} {}", platform::short_date(created), version.user), - None => version.user.clone(), - }; + others += 1; + let height = page_row( + ui, + theme, + section, + session, + found, + rounding, + renaming.as_deref_mut(), + (space, title, *level), + shift, + false, + &mut rows, + ); + laid += height; + natural += height; + } + if let (Some((space, title, level)), Some(dragged)) = (lifted, dragged) { + let gap = gap.unwrap_or(laid); + let key = ui.id((space, "dragged")); + let top = match dragged.top { + Some(top) => ui.hold(key, top), + None => { + let top = ui.animate(key, gap); + rows.settled = (top - gap).abs() < 0.5; + top + } + }; + page_row( + ui, + theme, + section, + session, + found, + rounding, + renaming, + (space, title, *level), + top - laid, + dragged.top.is_some(), + &mut rows, + ); + } + rows +} + +/// The tab of page `space` with its versions or conflict pages beneath it while shown, +/// drawn `shift` down from where they are laid out, and `lifted` as a dragged tab is: +/// how tall they are. +#[allow(clippy::too_many_arguments)] +fn page_row( + ui: &mut Ui, + theme: &Theme, + section: &ui::Section, + session: &Session, + found: &HashSet, + rounding: f32, + renaming: Option<&mut rename::Renaming>, + (space, title, level): (&ExGuid, &String, u32), + shift: f32, + lifted: bool, + rows: &mut Rows, +) -> f32 { + let mut height = ROW; + let versions = session.versions(*space); + let tab = PageTab { + label: if title.is_empty() { + "Untitled page" + } else { + title + }, + dim: title.is_empty(), + indent: level.saturating_sub(1), + conflicted: !versions.is_empty(), + found: found.contains(space), + renaming: renaming + .filter(|renaming| renaming.page(*space)) + .map(|renaming| &mut renaming.name), + shift, + lifted, + }; + let selected = *space == session.space && session.version.is_none(); + if selected && !lifted { + rows.open = Some(ui.id(space)); + } + let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding); + rows.kept = rows.kept.or(kept); + if signal.clicked && !selected { + rows.clicked = Some(*space); + } + if signal.pressed && signal.unit != draw::edit::SelectionUnit::Grapheme { + rows.renamed = Some(*space); + } + if let Some(point) = signal.context { + rows.context = Some((*space, point)); + } + if signal.dragging { + rows.held = Some(*space); + } + if lifted { + return height; + } + // OneNote lists a page's conflict pages beneath it, each by its date and whose + // version it is, and its page versions alike. + let muted = ui::Section { + tab: ui::mix(section.tab, theme.chip, 0.6), + ..*section + }; + if session.shown_history == Some(*space) { + for version in session.page_versions(*space) { + let label = history::label(version); let tab = PageTab { label: &label, dim: false, indent: level.saturating_sub(1), conflicted: false, found: false, + renaming: None, + shift, + lifted: false, }; - let selected = version.space == session.space; + let selected = session.version == Some(version.context); if selected { - rows.open = Some(ui.id(version.space)); + rows.open = Some(ui.id(version.context)); } - if page_tab(ui, theme, &muted, &version.space, &tab, selected).clicked && !selected { - rows.clicked = Some(version.space); + height += ROW; + if page_tab(ui, theme, &muted, &version.context, tab, selected, rounding) + .0 + .clicked + && !selected + { + rows.version = Some((*space, version.context)); } } } - rows + if session.shown != Some(*space) { + return height; + } + for version in versions { + let label = match version.created { + Some(created) => format!("{} {}", platform::short_date(created), version.user), + None => version.user.clone(), + }; + let tab = PageTab { + label: &label, + dim: false, + indent: level.saturating_sub(1), + conflicted: false, + found: false, + renaming: None, + shift, + lifted: false, + }; + let selected = version.space == session.space; + if selected { + rows.open = Some(ui.id(version.space)); + } + height += ROW; + if page_tab(ui, theme, &muted, &version.space, tab, selected, rounding) + .0 + .clicked + && !selected + { + rows.clicked = Some(version.space); + } + } + height } /// A row of the page list. @@ -2864,23 +3489,38 @@ struct PageTab<'a> { conflicted: bool, /// A search found the page, which OneNote marks yellow. found: bool, + /// The name typed in the tab's rename field, while it shows one. + renaming: Option<&'a mut String>, + /// How far down from its place in the list it is drawn. + shift: f32, + /// Dragged: lifted over the others with a shadow. + lifted: bool, } /// A page's tab down the frame's right side: the open one is the page's colour and joins -/// it, the others float free of it as pills. +/// it, the others float free of it as pills rounded like the page. fn page_tab( ui: &mut Ui, theme: &Theme, section: &ui::Section, id: &ExGuid, - tab: &PageTab, + tab: PageTab, selected: bool, -) -> ui::Signal { - let pad = 10.0 + 16.0 * tab.indent as f32; + rounding: f32, +) -> (ui::Signal, Option) { + // A rename field's text stands where the label did. + let pad = 10.0 + 16.0 * tab.indent as f32 + - if tab.renaming.is_some() { + rename::PAD + } else { + 0.0 + }; let spec = Spec { flags: Flags::CLICKABLE, size: [px(PAGE_LIST), px(ROW)], pad: [pad, 0.0], + offset: [0.0, tab.shift], + shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]), ..Spec::default() }; let (spec, color) = if selected { @@ -2891,15 +3531,18 @@ fn page_tab( }; (spec, theme.paper_ink) } else { - let fill = if tab.found { - ui::mix(section.tab, draw::srgb(0xff, 0xd8, 0x30), 0.6) - } else { - section.tab + // A found page's yellow lies over the tab, hovered or not. + let tint = |fill| { + if tab.found { + ui::mix(fill, draw::srgb(0xff, 0xd8, 0x30), 0.6) + } else { + fill + } }; let spec = Spec { - fill: Some(fill), - hover_fill: Some(ui::mix(fill, section.accent, 0.3)), - radius: ROUNDING, + fill: Some(tint(section.tab)), + hover_fill: Some(tint(section.hover())), + radius: rounding, inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], // The label keeps its place as the tab opens and closes. pad: [pad - PILL_MARGIN, 0.0], @@ -2913,15 +3556,22 @@ fn page_tab( (spec, color) }; let row = ui.open(id, spec); - ui.leaf( - "label", - Spec { - size: [fill(), px(ROW - ROW_GAP)], - text: Some(tab.label), - color: Some(color), - ..Spec::default() - }, - ); + let kept = match tab.renaming { + Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP), + None => { + ui.leaf( + "label", + Spec { + size: [fill(), px(ROW - ROW_GAP)], + text: Some(tab.label), + overflow: ui::Overflow::Ellipsis, + color: Some(color), + ..Spec::default() + }, + ); + None + } + }; if tab.conflicted { ui.leaf( "conflict", @@ -2935,7 +3585,60 @@ fn page_tab( ); } ui.close(); - ui.signal(row) + (ui.signal(row), kept) +} + +/// Puts `item` first among `recent` picks. +fn remember(recent: &mut Vec, item: T) { + recent.retain(|known| *known != item); + recent.insert(0, item); + recent.truncate(RECENT); +} + +/// Builds popup `id` as a gallery of a list library of `count` styles after None, under +/// `headings[1]`, and above it the library places `recent` under `headings[0]`, with library +/// place `current` outlined; `preview` builds a place's cell. Returns the place chosen, or +/// None for None. +#[allow(clippy::too_many_arguments)] +fn list_gallery( + ui: &mut Ui, + id: Id, + anchor: ui::Anchor, + headings: [&str; 2], + (recent, count): (&[usize], usize), + current: Option, + size: [f32; 2], + preview: impl Fn(&mut Ui, usize), +) -> Option> { + let groups = [(headings[0], recent.len()), (headings[1], count + 1)]; + let place = |index: usize| match index.checked_sub(recent.len()) { + None => Some(recent[index]), + Some(at) => at.checked_sub(1), + }; + let chosen = ui::popup::gallery( + ui, + id, + anchor, + &groups[usize::from(recent.is_empty())..], + 5, + size, + current.map(|place| recent.len() + 1 + place), + |ui, index| match place(index) { + Some(place) => preview(ui, place), + None => { + ui.leaf( + "none", + Spec { + size: [fill(), fill()], + text: Some("None"), + center: true, + ..Spec::default() + }, + ); + } + }, + )?; + Some(place(chosen)) } /// A toolbar group's rows, stacked. @@ -2997,6 +3700,7 @@ fn read_session( .ok_or("The section has no pages")?; let page = section.page(space)?; let conflicts = section.conflicts()?; + let history = section.versions()?; Ok(( Session { section, @@ -3009,6 +3713,9 @@ fn read_session( conflicts, shown: None, change: None, + history, + shown_history: None, + version: None, }, page, )) @@ -3111,9 +3818,15 @@ impl ApplicationHandler for App { } return; } - UserEvent::Options => { + UserEvent::Choose(choice) => { if let Some(state) = &mut self.state { - state.open_options(); + state.choose(choice); + } + return; + } + UserEvent::Appearance => { + if let Some(state) = &mut self.state { + state.follow_color_scheme(); state.window.request_redraw(); } return; @@ -3132,7 +3845,7 @@ impl ApplicationHandler for App { Replay::Tick => {} Replay::Appearance(appearance) => { state.window.set_theme(Some(appearance)); - state.ui.theme = theme(appearance, state.light_pages); + state.set_appearance(appearance); } Replay::Quit => { self.close(event_loop); @@ -3183,7 +3896,6 @@ impl ApplicationHandler for App { self.proxy.clone(), self.input.take().unwrap(), &self.substitutes, - std::mem::take(&mut self.interface_font), self.screenshot.is_none(), self.launch.take().expect("The window opens once"), )) { @@ -3230,8 +3942,9 @@ impl ApplicationHandler for App { WindowEvent::RedrawRequested => return state.frame(), WindowEvent::Resized(size) => { if size.width > 0 && size.height > 0 { - state.config.width = size.width; - state.config.height = size.height; + let ratio = state.scale() / scale; + state.config.width = (size.width as f32 * ratio) as u32; + state.config.height = (size.height as f32 * ratio) as u32; state .surface .configure(&state.renderer.device, &state.config); @@ -3241,15 +3954,12 @@ impl ApplicationHandler for App { return state.frame(); } WindowEvent::ThemeChanged(appearance) => { - let chosen = state.color_scheme.theme().unwrap_or(appearance); - state.ui.theme = theme(chosen, state.light_pages); + state.set_appearance(state.color_scheme.theme().unwrap_or(appearance)); state.window.request_redraw(); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { state.renderer.clear_glyph_cache(); state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32); - let response = state.view.scale_factor_changed(scale_factor as f32)?; - state.respond(response); state.window.request_redraw(); } WindowEvent::Focused(focused) => { @@ -3423,7 +4133,6 @@ fn main() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); let mut positional = Vec::new(); let mut substitutes = Vec::new(); - let mut interface_font = Vec::new(); let mut reference = None; let mut editable = false; let mut section = None; @@ -3437,27 +4146,6 @@ fn main() -> Result<(), Box> { args.next() .ok_or("Provide a font file after --substitute-font.")?, )); - } else if arg == "--ui-font" { - let path = PathBuf::from( - args.next() - .ok_or("Provide a font file or folder after --ui-font.")?, - ); - let mut files = if path.is_dir() { - std::fs::read_dir(&path)? - .map(|entry| entry.map(|entry| entry.path())) - .collect::, _>>()? - } else { - vec![path] - }; - files.retain(|file| { - file.extension().is_some_and(|extension| { - matches!(extension.to_str(), Some("otf" | "ttf" | "ttc")) - }) - }); - files.sort(); - for file in files { - interface_font.push(std::fs::read(file)?); - } } else if arg == "--notebook" { notebook = Some(PathBuf::from( args.next() @@ -3484,7 +4172,8 @@ fn main() -> Result<(), Box> { )); } else if arg == "--settings" { settings_file = Some(PathBuf::from( - args.next().ok_or("Provide a settings file after --settings.")?, + args.next() + .ok_or("Provide a settings file after --settings.")?, )); } else if arg == "--screenshot" { screenshot = Some(PathBuf::from( @@ -3525,7 +4214,7 @@ fn main() -> Result<(), Box> { } if positional.len() > 2 { return Err( - "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--ui-font FONT_FILE_OR_FOLDER] [--substitute-font FONT_FILE]..." + "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..." .into(), ); } @@ -3592,7 +4281,6 @@ fn main() -> Result<(), Box> { cache, }), substitutes, - interface_font, screenshot, state: None, startup_error: None, diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 3e750314d4ba8db3fe39df769ea35fc52e417db6..ed3821065879429ac6aba98cc65eacb82bbcad35 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -52,6 +52,13 @@ fn follow(current: &str, from: &str, to: &str) -> String { } } +/// The section `library` shows after a change: `open` where it still lists it, else its +/// first, or none once it has no sections. +fn shown(library: &Library, open: Option) -> Option { + open.filter(|path| library.contains(path)) + .or_else(|| library.first_section()) +} + /// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names /// new sections ("New Section 1", …) and groups. fn unused(taken: &[String], base: &str, numbered: bool) -> String { @@ -162,13 +169,16 @@ impl State { /// Closes `library`: its files stay, and the sidebar and the next launch leave it out. pub(crate) fn close_notebook(&mut self, library: &Arc) { self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); - if self - .session - .as_ref() - .is_some_and(|session| Arc::ptr_eq(&session.library, library)) + let shown = |shown: &Arc| shown.location == library.location; + if self.sectionless.as_ref().is_some_and(shown) + || self + .session + .as_ref() + .is_some_and(|session| shown(&session.library)) { self.persist().unwrap_or_else(|error| eprintln!("{error}")); self.session = None; + self.sectionless = None; self.templates = crate::templates::View::Strip; match self .notebooks @@ -176,7 +186,10 @@ impl State { .find_map(|open| Some((Arc::clone(open), open.first_section()?))) { Some((open, path)) => self.commands.push(Command::OpenSection(open, path)), - None => self.title(), + None => { + self.sectionless = self.notebooks.first().cloned(); + self.title(); + } } } self.save_settings(); @@ -257,20 +270,24 @@ impl State { (current, None) } }; + let fresh = created.is_some(); let open = created.or(followed.clone()); let library = Arc::new(library.with(notebook)); - let path = open - .filter(|path| library.contains(path)) - .or_else(|| library.first_section()) - .ok_or("The notebook has no sections left")?; + let Some(path) = shown(&library, open) else { + return Ok(Loaded::Library(library, None)); + }; // The open section, perhaps renamed or moved, stays open: its replica is this // thread's to reopen only where no section holds it. if followed.as_deref() == Some(path.as_str()) { - return Ok(Loaded::Library(library, path)); + return Ok(Loaded::Library(library, Some(path))); } let section = library.open(&path, notify)?; let (session, page) = read_session(section, library, path, None)?; - Ok(Loaded::Section(Box::new(session), page)) + Ok(if fresh { + Loaded::Created(Box::new(session), page) + } else { + Loaded::Section(Box::new(session), page) + }) }); } @@ -765,6 +782,36 @@ mod tests { std::fs::remove_dir_all(&temporary).unwrap(); } + /// Deleting a group's last section shows the notebook's other one; deleting that too + /// leaves the notebook showing no section, where it failed before. + #[test] + fn deleting_every_section_leaves_the_notebook_showing_none() { + let temporary = + std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + std::fs::create_dir_all(&temporary).unwrap(); + let root = temporary.join("Emptied"); + let location = root.to_str().unwrap(); + let cache = temporary.join("cache"); + let mut notebook = Notebook::create(location, &cache, NOTEBOOK_COLOR, &dated()).unwrap(); + notebook.create_group("", "Group").unwrap(); + notebook.create_section("Group", "Inner", &dated()).unwrap(); + + notebook.delete("Group/Inner.one").unwrap(); + let library = Library::created(location, notebook, &cache); + assert_eq!( + shown(&library, Some("Group/Inner.one".into())).as_deref(), + Some("New Section 1.one") + ); + + let mut notebook = library.reopen().unwrap(); + notebook.delete("New Section 1.one").unwrap(); + let library = library.with(notebook); + assert_eq!(shown(&library, Some("New Section 1.one".into())), None); + assert!(library.tabs("").is_empty()); + std::fs::remove_dir_all(&temporary).unwrap(); + } + #[test] fn moved_sections_are_followed_and_new_names_take_the_next_number() { assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one"); diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs new file mode 100644 index 0000000000000000000000000000000000000000..ac86bb610f7382c2a744a4172606d6a76480408a --- /dev/null +++ b/crates/snowbound/src/menubar.rs @@ -0,0 +1,407 @@ +//! The macOS menu bar, laid out as OneNote for Mac's: the command table's commands, and +//! AppKit's own items where the system provides them. + +use crate::commands::{self, Choice, Chord, Id, cmd, named}; +use canvas::editor::{Alignment, NoteTag, Toggle}; +use draw::edit::NamedKey; +use objc2::{ + rc::Retained, + runtime::{AnyObject, Sel}, +}; +use objc2_app_kit::{NSEventModifierFlags, NSMenu, NSMenuItem}; +use objc2_foundation::{MainThreadMarker, NSString}; + +enum Item { + Command(Id), + /// AppKit's own: its title, its action's selector and its chord. + System(&'static str, &'static str, Option), + Separator, + Menu(&'static str, &'static [Item]), + /// The system's Services menu. + Services, + /// Each of a list's choices. + Fonts, + Sizes, + Highlights, +} + +use Item::{Command as C, Separator as S}; + +const MENUS: &[Item] = &[ + Item::Menu( + "Snowbound", + &[ + Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None), + S, + C(Id::Settings), + S, + Item::Services, + S, + Item::System("Hide Snowbound", "hide:", Some(cmd('h'))), + Item::System( + "Hide Others", + "hideOtherApplications:", + Some(cmd('h').option()), + ), + Item::System("Show All", "unhideAllApplications:", None), + S, + Item::System("Quit Snowbound", "terminate:", Some(cmd('q'))), + ], + ), + Item::Menu( + "File", + &[ + C(Id::NewNotebook), + C(Id::OpenNotebook), + C(Id::CloseNotebook), + S, + C(Id::NewSection), + C(Id::NewSectionGroup), + C(Id::NewPage), + C(Id::NewSubpage), + S, + C(Id::PageVersions), + C(Id::CopyPageLink), + C(Id::ShowNotebook), + ], + ), + Item::Menu( + "Edit", + &[ + C(Id::Undo), + C(Id::Redo), + S, + C(Id::Cut), + C(Id::Copy), + C(Id::Paste), + C(Id::SelectAll), + S, + C(Id::Find), + C(Id::Search), + S, + Item::System("Start Dictation…", "startDictation:", None), + Item::System( + "Emoji & Symbols", + "orderFrontCharacterPalette:", + Some(named(NamedKey::Space).command().control()), + ), + ], + ), + // AppKit appends Enter Full Screen itself, and would repeat one listed here. + Item::Menu( + "View", + &[ + C(Id::Back), + C(Id::Forward), + S, + C(Id::ZoomIn), + C(Id::ZoomOut), + C(Id::ActualSize), + S, + C(Id::Sidebar), + C(Id::PageList), + C(Id::DarkPages), + ], + ), + Item::Menu( + "Insert", + &[ + C(Id::Table), + C(Id::Picture), + C(Id::Link), + C(Id::Equation), + S, + C(Id::Date), + C(Id::Time), + C(Id::DateTime), + ], + ), + Item::Menu( + "Format", + &[ + Item::Menu("Font", &[Item::Fonts]), + Item::Menu("Size", &[Item::Sizes]), + S, + C(Id::Toggle(Toggle::Bold)), + C(Id::Toggle(Toggle::Italic)), + C(Id::Toggle(Toggle::Underline)), + C(Id::Toggle(Toggle::Strikethrough)), + C(Id::Toggle(Toggle::Subscript)), + C(Id::Toggle(Toggle::Superscript)), + S, + C(Id::Highlight), + Item::Menu("Highlight Color", &[Item::Highlights]), + C(Id::FontColor), + S, + C(Id::Bullets), + C(Id::Numbering), + S, + C(Id::Align(Alignment::Left)), + C(Id::Align(Alignment::Center)), + C(Id::Align(Alignment::Right)), + S, + C(Id::Indent), + C(Id::Outdent), + S, + C(Id::ClearFormatting), + ], + ), + Item::Menu( + "Tags", + &[ + C(Id::Tag(NoteTag::ToDo)), + C(Id::Tag(NoteTag::Important)), + C(Id::Tag(NoteTag::Question)), + C(Id::Tag(NoteTag::RememberForLater)), + C(Id::Tag(NoteTag::Definition)), + C(Id::Tag(NoteTag::Highlight)), + C(Id::Tag(NoteTag::Contact)), + C(Id::Tag(NoteTag::Address)), + C(Id::Tag(NoteTag::PhoneNumber)), + S, + C(Id::RemoveTags), + ], + ), + Item::Menu( + "Window", + &[ + Item::System("Minimize", "performMiniaturize:", Some(cmd('m'))), + Item::System("Zoom", "performZoom:", None), + S, + Item::System("Bring All to Front", "arrangeInFront:", None), + ], + ), + Item::Menu("Help", &[C(Id::Help)]), +]; + +/// The bar, and the menus AppKit fills itself. +pub struct Menus { + pub bar: Retained, + pub services: Retained, + pub window: Retained, + pub help: Retained, +} + +/// Builds the bar; the table's items send `choose:` to `target`, which validates them. +pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { + let services = NSMenu::new(mtm); + let bar = NSMenu::new(mtm); + let mut submenus = Vec::new(); + for item in MENUS { + let Item::Menu(title, items) = item else { + unreachable!("The bar holds menus") + }; + let menu = menu(mtm, target, title, items, &services); + let holder = NSMenuItem::new(mtm); + holder.setSubmenu(Some(&menu)); + bar.addItem(&holder); + submenus.push(menu); + } + let named = |name: &str| { + submenus + .iter() + .find(|menu| unsafe { menu.title() }.to_string() == name) + .cloned() + .expect("The bar has the menus AppKit fills") + }; + Menus { + window: named("Window"), + help: named("Help"), + services, + bar, + } +} + +fn menu( + mtm: MainThreadMarker, + target: &AnyObject, + title: &str, + items: &[Item], + services: &NSMenu, +) -> Retained { + let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) }; + let choose = |choice: &Choice, title: &str, chord: Option| { + let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord); + // The application validates and runs the table's items whatever holds focus. + unsafe { + item.setTarget(Some(target)); + item.setTag(commands::index(choice) as isize); + } + menu.addItem(&item); + }; + let list = |pick: fn(&Choice) -> bool| { + for choice in commands::choices().filter(pick) { + choose(&choice, &commands::title(choice.clone()), None); + } + }; + for item in items { + match item { + Item::Command(id) => { + let command = commands::command(*id); + choose( + &Choice::Command(*id), + command.title, + command.mac.first().copied(), + ); + } + Item::System(title, action, chord) => { + menu.addItem(&entry(mtm, title, Some(Sel::register(action)), *chord)); + } + Item::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)), + Item::Menu(title, items) => { + let holder = entry(mtm, title, None, None); + holder.setSubmenu(Some(&self::menu(mtm, target, title, items, services))); + menu.addItem(&holder); + } + Item::Services => { + let holder = entry(mtm, "Services", None, None); + holder.setSubmenu(Some(services)); + menu.addItem(&holder); + } + Item::Fonts => list(|choice| matches!(choice, Choice::Font(_))), + Item::Sizes => list(|choice| matches!(choice, Choice::Size(_))), + Item::Highlights => list(|choice| matches!(choice, Choice::Highlight(_))), + } + } + menu +} + +fn entry( + mtm: MainThreadMarker, + title: &str, + action: Option, + chord: Option, +) -> Retained { + let (key, shift) = chord.map_or((String::new(), false), Chord::equivalent); + let item = unsafe { + NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str(title), + action, + &NSString::from_str(&key), + ) + }; + if let Some(chord) = chord { + let mut mask = NSEventModifierFlags(0); + for (held, flag) in [ + (shift, NSEventModifierFlags::NSEventModifierFlagShift), + ( + chord.option, + NSEventModifierFlags::NSEventModifierFlagOption, + ), + ( + chord.control, + NSEventModifierFlags::NSEventModifierFlagControl, + ), + ( + chord.command, + NSEventModifierFlags::NSEventModifierFlagCommand, + ), + ] { + if held { + mask.0 |= flag.0; + } + } + item.setKeyEquivalentModifierMask(mask); + } + item +} + +#[cfg(test)] +mod tests { + use super::*; + use draw::edit::Platform; + + /// The menu tree as the bar shows it: titles, chords, and each table item's choice. + fn dump(menu: &NSMenu, depth: usize, out: &mut String, chords: &mut Vec) { + for index in 0..unsafe { menu.numberOfItems() } { + let item = unsafe { menu.itemAtIndex(index) }.expect("Items within the count"); + let indent = " ".repeat(depth); + if unsafe { item.isSeparatorItem() } { + out.push_str(&format!("{indent}---\n")); + continue; + } + let mask = unsafe { item.keyEquivalentModifierMask() }.0; + let key = unsafe { item.keyEquivalent() }.to_string(); + let chord = if key.is_empty() { + String::new() + } else { + let symbols = [ + (NSEventModifierFlags::NSEventModifierFlagControl, "⌃"), + (NSEventModifierFlags::NSEventModifierFlagOption, "⌥"), + (NSEventModifierFlags::NSEventModifierFlagShift, "⇧"), + (NSEventModifierFlags::NSEventModifierFlagCommand, "⌘"), + ]; + let held: String = symbols + .iter() + .filter(|(flag, _)| mask & flag.0 != 0) + .map(|(_, symbol)| *symbol) + .collect(); + let key = match key.as_str() { + " " => "Space".to_owned(), + "\u{f702}" => "←".to_owned(), + "\u{f703}" => "→".to_owned(), + key => key.to_uppercase(), + }; + format!("{held}{key}") + }; + if !chord.is_empty() { + chords.push(chord.clone()); + } + let action = unsafe { item.action() } + .filter(|action| *action != objc2::sel!(submenuAction:)) + .map_or(String::new(), |action| { + if action == objc2::sel!(choose:) { + let tag = unsafe { item.tag() } as usize; + format!( + "{:?}", + commands::choices().nth(tag).expect("Tags index choices") + ) + } else { + action.name().to_owned() + } + }); + let submenu = unsafe { item.submenu() }; + // The bar's items show their menu's title. + let title = match &submenu { + Some(submenu) if depth == 0 => unsafe { submenu.title() }.to_string(), + _ => unsafe { item.title() }.to_string(), + }; + out.push_str(format!("{indent}{title:<24} {chord:<8} {action}").trim_end()); + out.push('\n'); + if let Some(submenu) = submenu { + dump(&submenu, depth + 1, out, chords); + } + } + } + + #[test] + fn menu_bar_offers_each_chord_once_as_the_keyboard_does() { + // Built off the main thread for the test only; nothing shows or runs. + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + let target: Retained = + unsafe { objc2::msg_send_id![objc2::class!(NSObject), new] }; + let menus = build(mtm, &target); + let (mut tree, mut chords) = (String::new(), Vec::new()); + dump(&menus.bar, 0, &mut tree, &mut chords); + println!("{tree}"); + for (at, chord) in chords.iter().enumerate() { + assert!(!chords[at + 1..].contains(chord), "{chord} is on two items"); + } + // The keyboard's first chord for each command is on its menu item. + for command in commands::COMMANDS { + let Some(chord) = command.mac.first() else { + continue; + }; + let line = tree + .lines() + .find(|line| line.ends_with(&format!("Command({:?})", command.id))) + .unwrap_or_else(|| panic!("{} has a chord and no menu item", command.title)); + assert!( + line.contains(&chord.label(Platform::MacOs)), + "{line} shows {}", + chord.label(Platform::MacOs) + ); + } + } +} diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 48c3e7c07a9f9faeca75c604ea1db6f47e58ee3e..329a33766b6fc60b52f55b693256e98fe582376e 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -7,14 +7,45 @@ use std::sync::Arc; use ui::{Anchor, Id, popup::Item}; /// What a drag holds. -pub enum Drag { +pub enum Dragged { Page(ExGuid), - /// A sidebar row. - Entry(crate::sidebar::Entry), + /// A sidebar row, where it would land, and where it came from. + Entry { + entry: crate::sidebar::Entry, + landing: crate::sidebar::Landing, + home: crate::sidebar::Landing, + }, /// A section tab, by index. Tab(usize), } +/// Something held down to drag: where the pointer took it, whether it has moved far enough +/// to lift, and for rows, the index in their list where the gap it would land in opens. +pub struct Drag { + pub what: Dragged, + /// The pointer when pressed, and its distance from the box's corner. + from: [f32; 2], + grab: [f32; 2], + pub lifted: bool, + /// Escape called it off; it ends when the button comes up. + pub cancelled: bool, + /// Let go: what it held eases into its place, then the drag ends. + pub released: bool, + pub slot: Option, +} + +impl Drag { + /// Whether it follows the pointer: lifted, held, and not called off. + pub fn live(&self) -> bool { + self.lifted && !self.cancelled && !self.released + } + + /// Where the dragged box's corner follows the pointer at `pointer`. + pub fn corner(&self, pointer: [f32; 2]) -> [f32; 2] { + [pointer[0] - self.grab[0], pointer[1] - self.grab[1]] + } +} + /// What a context menu was opened on. pub enum Target { Page(ExGuid), @@ -28,12 +59,72 @@ pub fn id() -> Id { } /// The catalog folder holding `path`. -fn folder(path: &str) -> String { +pub(crate) fn folder(path: &str) -> String { path.rsplit_once('/') .map_or(String::new(), |(folder, _)| folder.to_owned()) } impl State { + /// Follows a drag of what `held` names, pressed in its box's rectangle, this frame: + /// starts one, lifts it once the pointer has moved a few pixels as a click's doesn't, + /// and says when it was let go while it followed the pointer. `mine` tells this list's + /// drags from others'. A drag let go stays, as what it held eases into place, until + /// `settle` ends it. + pub(crate) fn follow_drag( + &mut self, + held: Option<(Dragged, [f32; 4])>, + mine: impl Fn(&Dragged) -> bool, + ) -> bool { + let pointer = self.ui.pointer(); + match (held, &mut self.drag) { + (Some(_), Some(drag)) if mine(&drag.what) && !drag.released => { + if let Some([x, y]) = pointer { + drag.lifted |= (x - drag.from[0]).hypot(y - drag.from[1]) > 4.0; + } + false + } + (Some((what, rect)), None | Some(Drag { released: true, .. })) => { + self.drag = pointer.map(|pointer| Drag { + what, + from: pointer, + grab: [pointer[0] - rect[0], pointer[1] - rect[1]], + lifted: false, + cancelled: false, + released: false, + slot: None, + }); + false + } + (None, Some(drag)) if mine(&drag.what) && !drag.released => { + let dropped = drag.live(); + drag.released = true; + if !drag.lifted { + self.drag = None; + } + dropped + } + _ => false, + } + } + + /// Whether something dragged is lifted, still following the pointer or easing into place, + /// so letting go of it isn't a click. + pub(crate) fn dragged(&self) -> bool { + self.drag.as_ref().is_some_and(|drag| drag.lifted) + } + + /// Ends this list's drag, let go, once what it held stands in its place. + pub(crate) fn settle(&mut self, mine: impl Fn(&Dragged) -> bool, placed: bool) { + if placed + && self + .drag + .as_ref() + .is_some_and(|drag| drag.released && mine(&drag.what)) + { + self.drag = None; + } + } + /// The open context menu, and what its choice does. pub(crate) fn context_menu(&mut self) { let Some((target, point)) = &self.menu else { @@ -58,8 +149,18 @@ impl State { let above = at .and_then(|at| at.checked_sub(1)) .map_or(0, |above| pages[above].2); + let (versions, shown) = self.session.as_ref().map_or((false, false), |session| { + ( + !session.page_versions(*space).is_empty(), + session.shown_history == Some(*space), + ) + }); vec![ item("Delete"), + Item { + separated: true, + ..item("Copy Link to Page") + }, Item { separated: true, ..item("New Page") @@ -75,6 +176,15 @@ impl State { disabled: level <= 1, ..item("Promote Subpage") }, + Item { + separated: true, + disabled: !versions, + ..item(if shown { + "Hide Page Versions" + } else { + "Show Page Versions" + }) + }, ] } Target::Section { .. } | Target::Group { .. } => vec![ @@ -104,8 +214,19 @@ impl State { }; let command = match (target, items[chosen].text) { (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])), + (Target::Page(space), "Copy Link to Page") => { + let copied = self + .page_link(space, None) + .and_then(|link| Ok(self.clipboard.set_text(link)?)); + if let Err(error) = copied { + eprintln!("Copying the link failed: {error}"); + } + None + } (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }), (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }), + (Target::Page(page), "Show Page Versions") => Some(Command::History { page, show: true }), + (Target::Page(page), "Hide Page Versions") => Some(Command::History { page, show: false }), (Target::Page(space), text) => self.session.as_ref().and_then(|session| { let level = session .pages @@ -123,13 +244,11 @@ impl State { Target::Section { library, path } | Target::Group { library, path }, "Rename", ) => { - let name = path.rsplit('/').next().unwrap_or_default(); - self.renaming = Some(crate::sidebar::Renaming { + self.rename(crate::rename::Target::Entry { library, - name: name.strip_suffix(".one").unwrap_or(name).to_owned(), path, + in_tab: false, }); - self.sidebar = true; None } (Target::Section { library, path }, "Delete") => platform::confirm( @@ -166,8 +285,18 @@ impl State { /// The section tab, of those built as `row`, under the pointer while a page is dragged /// there, which a drop moves the page into. pub(crate) fn page_drop(&self, row: Id) -> Option { - let (Some(Drag::Page(_)), Some(session), Some([x, y])) = - (&self.drag, &self.session, self.ui.pointer()) + let ( + Some(Drag { + what: Dragged::Page(_), + .. + }), + Some(session), + Some([x, y]), + ) = ( + self.drag.as_ref().filter(|drag| drag.live()), + &self.session, + self.ui.pointer(), + ) else { return None; }; @@ -180,156 +309,166 @@ impl State { }) } - /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let - /// go, it moves there at its level, or under a page at most one level above it. Onto a - /// section tab of those built as `row`, it moves to the end of that section, as - /// OneNote moves a page dropped on a section's tab. `panel` is the page list's rectangle. + /// The page tab dragged, as the page list, built as the current box, shows it. + pub(crate) fn dragged_page(&self) -> Option { + let drag = self.drag.as_ref().filter(|drag| drag.lifted)?; + let Dragged::Page(space) = drag.what else { + return None; + }; + let origin = self.ui.rect(self.ui.id(crate::page_list_top()))?[1]; + Some(crate::PageDrag { + space, + top: drag.live().then(|| drag.corner(self.pointer)[1] - origin), + slot: drag.slot, + }) + } + + /// Moves a page by dragging its tab, `held` in the page list whose tabs stand at + /// `places`: let go, it moves where the gap the other tabs opened for it is, at its + /// level, or under a page at most one level above it. Onto a section tab of those built + /// as `row`, it moves to the end of that section, as OneNote moves a page dropped on a + /// section's tab. pub(crate) fn drag_pages( &mut self, held: Option, - section: &ui::Section, - panel: [f32; 4], + places: &[f32], + settled: bool, row: Id, ) { - let Some(session) = &self.session else { - return; - }; - let dropped = match (&self.drag, held) { - (_, Some(space)) => Some(space), - (Some(Drag::Page(space)), None) => Some(*space), - _ => None, - }; + let mine = |what: &Dragged| matches!(what, Dragged::Page(_)); + self.settle(mine, settled); + let held = + held.and_then(|space| Some((Dragged::Page(space), self.ui.rect(self.ui.id(space))?))); let target = self.page_drop(row); - if let Some(space) = held { - self.drag = Some(Drag::Page(space)); - } else if matches!(self.drag, Some(Drag::Page(_))) { + let origin = self.ui.rect(self.ui.id(crate::page_list_top())); + let dropped = self.follow_drag(held, mine); + let ( + Some(session), + Some( + drag @ Drag { + what: Dragged::Page(_), + .. + }, + ), + ) = (&self.session, &mut self.drag) + else { + return; + }; + let Dragged::Page(space) = drag.what else { + return; + }; + let Some(from) = session + .pages + .iter() + .position(|(listed, ..)| *listed == space) + else { + // The page left the section, dropped on another's tab. self.drag = None; - } - let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else { return; }; + if drag.cancelled { + drag.slot = None; + } else if drag.live() + && let Some(origin) = origin + { + let middle = drag.corner(self.pointer)[1] - origin[1] + crate::ROW / 2.0; + let spans: Vec<[f32; 2]> = places.iter().map(|top| [*top, crate::ROW]).collect(); + drag.slot = Some(if target.is_some() { + from + } else { + ui::drop_slot(&spans, from, middle) + }); + } + if !dropped { + return; + } + let slot = drag.slot.take(); if let Some(tab) = target { - if held.is_none() { - let path = session.tabs[tab].path.clone(); - self.commands.push(Command::MovePage { space, path }); - } + let path = session.tabs[tab].path.clone(); + self.commands.push(Command::MovePage { space, path }); return; } - // Rows other than the dragged one, with where each lies. - let rows: Vec<(ExGuid, u32, [f32; 4])> = session + let Some(slot) = slot.filter(|slot| *slot != from) else { + return; + }; + let others: Vec<_> = session .pages .iter() .filter(|(listed, ..)| *listed != space) - .filter_map(|(listed, _, level)| { - Some((*listed, *level, self.ui.rect(self.ui.id(listed))?)) - }) .collect(); - let Some(level) = session - .pages - .iter() - .find(|(listed, ..)| *listed == space) - .map(|(.., level)| *level) - else { - return; - }; - let at = rows - .iter() - .position(|(.., rect)| y < (rect[1] + rect[3]) / 2.0) - .unwrap_or(rows.len()); - let before = rows.get(at).map(|(listed, ..)| *listed); - let level = at + let before = others.get(slot).map(|(listed, ..)| *listed); + let level = slot .checked_sub(1) - .map_or(1, |above| level.min(rows[above].1 + 1)); - let stays = session - .pages - .iter() - .skip_while(|(listed, ..)| *listed != space) - .nth(1) - .map(|(listed, ..)| *listed) - == before; - if stays { - return; - } - if held.is_some() { - // The line lies between the rows the page would go between. - let line = match (at.checked_sub(1).map(|above| rows[above].2), rows.get(at)) { - (_, Some((.., below))) => below[1], - (Some(above), None) => above[3], - (None, None) => return, - } - panel[1]; - let indent = 10.0 + 16.0 * (level - 1) as f32; - self.ui.mark( - [indent, line - 1.5, crate::PAGE_LIST - 6.0, line + 1.5], - section.accent, - 1.5, - ); - } else if let Ok(edit) = PageEdit::move_to(space, before, level) { + .map_or(1, |above| session.pages[from].2.min(others[above].2 + 1)); + if let Ok(edit) = PageEdit::move_to(space, before, level) { self.commands.push(Command::Pages(vec![edit])); } } - /// Reorders section tabs by dragging one: while `held`, a line shows where it would go - /// among the tabs built as `row` in the tab row `bar`; let go, the folder takes that - /// order. + /// The section tab dragged, as the tabs built as `row` show it: its index, and its + /// leading edge following the pointer along the row. + pub(crate) fn dragged_tab(&self, row: Id) -> Option { + let drag = self.drag.as_ref().filter(|drag| drag.lifted)?; + let Dragged::Tab(index) = drag.what else { + return None; + }; + let start = drag.corner(self.pointer)[0] - self.ui.rect(row)?[0]; + Some(ui::shell::Dragged { + index, + start: drag.live().then_some(start), + }) + } + + /// Reorders section tabs by dragging one, as browser tabs reorder: let go, the tab + /// `held` of those built as `row` lands at `slot`, and the folder takes that order. The + /// tabs take it at once, so none slides back while the notebook changes. pub(crate) fn drag_tabs( &mut self, held: Option, + slot: Option, + settled: bool, row: Id, - bar: [f32; 4], - accent: [f32; 4], ) { - let Some(session) = &self.session else { + let mine = |what: &Dragged| matches!(what, Dragged::Tab(_)); + self.settle(mine, settled); + let held = held.and_then(|tab| { + Some(( + Dragged::Tab(tab), + self.ui.rect(ui::shell::tab_id(row, tab))?, + )) + }); + let dropped = self.follow_drag(held, mine); + let ( + true, + Some(Drag { + what: Dragged::Tab(tab), + .. + }), + Some(slot), + Some(session), + ) = (dropped, &mut self.drag, slot, &mut self.session) + else { return; }; - let dragged = match (&self.drag, held) { - (_, Some(tab)) => Some(tab), - (Some(Drag::Tab(tab)), None) => Some(*tab), - _ => None, - }; - if let Some(tab) = held { - self.drag = Some(Drag::Tab(tab)); - } else if matches!(self.drag, Some(Drag::Tab(_))) { - self.drag = None; + // It eases into its new place from where it was let go. + let tab = std::mem::replace(tab, slot); + if slot == tab { + return; } - let (Some(tab), Some([x, _])) = (dragged, self.ui.pointer()) else { - return; - }; - let others: Vec<(usize, [f32; 4])> = (0..session.tabs.len()) - .filter(|other| *other != tab) - .filter_map(|other| Some((other, self.ui.rect(ui::shell::tab_id(row, other))?))) - .collect(); - let at = others + let open = session.tabs[session.tab].path.clone(); + let moved = session.tabs.remove(tab); + session.tabs.insert(slot, moved); + session.tab = session + .tabs .iter() - .position(|(_, rect)| x < (rect[0] + rect[2]) / 2.0) - .unwrap_or(others.len()); - let mut order: Vec = others.iter().map(|(other, _)| *other).collect(); - order.insert(at, tab); - if order.iter().copied().eq(0..session.tabs.len()) { - return; - } - if held.is_some() { - let edge = match ( - at.checked_sub(1).map(|before| others[before].1), - others.get(at), - ) { - (_, Some((_, after))) => after[0], - (Some(before), None) => before[2], - (None, None) => return, - } - bar[0]; - self.ui - .mark([edge - 1.5, 4.0, edge + 1.5, bar[3] - bar[1]], accent, 1.5); - } else { - let path = &session.tabs[tab].path; - let folder = folder(path); - let paths = order - .into_iter() - .map(|index| session.tabs[index].path.clone()) - .collect(); - self.commands.push(Command::Structure( - Arc::clone(&session.library), - Structure::Reorder { folder, paths }, - )); - } + .position(|tab| tab.path == open) + .unwrap_or_default(); + let folder = folder(&open); + let paths = session.tabs.iter().map(|tab| tab.path.clone()).collect(); + self.commands.push(Command::Structure( + Arc::clone(&session.library), + Structure::Reorder { folder, paths }, + )); } } diff --git a/crates/snowbound/src/navigation.rs b/crates/snowbound/src/navigation.rs new file mode 100644 index 0000000000000000000000000000000000000000..b69c1aee9500126bffc164c71e3d7c02a20ba658 --- /dev/null +++ b/crates/snowbound/src/navigation.rs @@ -0,0 +1,175 @@ +//! Back and Forward through the pages visited, across sections and notebooks, as OneNote +//! 2010's Quick Access Toolbar offers them. + +use super::*; + +/// A page shown: its notebook's location, its section's catalog path and the page. +#[derive(Clone, Debug, PartialEq)] +pub struct Place { + pub notebook: String, + pub section: String, + pub page: ExGuid, +} + +/// Pages visited before and after the one shown. +#[derive(Default)] +pub struct Trail { + back: Vec, + here: Option, + forward: Vec, +} + +/// Places Back keeps, at most. +const KEPT: usize = 100; + +impl Trail { + /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages + /// ahead, as a browser does. + pub fn visit(&mut self, place: Place) { + if self.here.as_ref() == Some(&place) { + return; + } + if let Some(here) = self.here.replace(place) { + self.back.push(here); + if self.back.len() > KEPT { + self.back.remove(0); + } + } + self.forward.clear(); + } + + /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it + /// refuses; the place to show. + pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option { + let (from, to) = if forward { + (&mut self.forward, &mut self.back) + } else { + (&mut self.back, &mut self.forward) + }; + let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?; + if let Some(here) = self.here.replace(place.clone()) { + to.push(here); + } + Some(place) + } + + /// Whether Back and Forward have somewhere to go. + pub fn open(&self) -> [bool; 2] { + [!self.back.is_empty(), !self.forward.is_empty()] + } +} + +impl State { + /// Notes the page just shown for Back and Forward. + pub(crate) fn visited(&mut self) { + if let Some(session) = &self.session { + self.trail.visit(Place { + notebook: session.library.location.clone(), + section: session.tabs[session.tab].path.clone(), + page: session.space, + }); + } + } + + /// Shows the page visited before the one shown, or after it going `forward`. A page + /// deleted from the open section since is passed over. + pub(crate) fn travel(&mut self, forward: bool) { + let session = self.session.as_ref(); + let exists = |place: &Place| { + session.is_none_or(|session| { + session.library.location != place.notebook + || session.tabs[session.tab].path != place.section + || session.pages.iter().any(|(page, ..)| *page == place.page) + }) + }; + let Some(place) = self.trail.step(forward, exists) else { + return; + }; + if let Some(session) = &self.session + && session.library.location == place.notebook + && session.tabs[session.tab].path == place.section + { + self.commands.push(Command::OpenPage(place.page)); + return; + } + let Some(library) = self + .notebooks + .iter() + .find(|library| library.location == place.notebook) + else { + return; + }; + let library = Arc::clone(library); + self.last_pages + .insert(library.key(&place.section), place.page); + self.commands + .push(Command::OpenSection(library, place.section)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn place(section: &str, page: u32) -> Place { + Place { + notebook: "/notebooks/Personal".into(), + section: section.into(), + page: ExGuid { + guid: [7; 16], + n: page, + }, + } + } + + #[test] + fn back_and_forward_retrace_visits_across_sections() { + let mut trail = Trail::default(); + assert_eq!(trail.open(), [false, false]); + for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] { + trail.visit(place(section, page)); + } + // Showing the same page again, as a reload does, is no visit. + trail.visit(place("B.one", 1)); + let all = |_: &Place| true; + assert_eq!(trail.step(false, all), Some(place("A.one", 2))); + // Arriving where Back went is not a new visit. + trail.visit(place("A.one", 2)); + assert_eq!(trail.open(), [true, true]); + assert_eq!(trail.step(false, all), Some(place("A.one", 1))); + assert_eq!(trail.step(false, all), None); + assert_eq!(trail.step(true, all), Some(place("A.one", 2))); + assert_eq!(trail.step(true, all), Some(place("B.one", 1))); + assert_eq!(trail.step(true, all), None); + // A page opened after going back drops the pages ahead. + trail.step(false, all); + trail.visit(place("C.one", 5)); + assert_eq!(trail.open(), [true, false]); + assert_eq!(trail.step(false, all), Some(place("A.one", 2))); + } + + #[test] + fn back_passes_over_pages_since_deleted() { + let mut trail = Trail::default(); + for page in 1..=3 { + trail.visit(place("A.one", page)); + } + let kept = |place: &Place| place.page.n != 2; + assert_eq!(trail.step(false, kept), Some(place("A.one", 1))); + assert_eq!(trail.step(true, kept), Some(place("A.one", 3))); + assert_eq!(trail.open(), [true, false]); + } + + #[test] + fn back_keeps_its_last_hundred_places() { + let mut trail = Trail::default(); + for page in 0..150 { + trail.visit(place("A.one", page)); + } + let mut steps = 0; + while trail.step(false, |_| true).is_some() { + steps += 1; + } + assert_eq!(steps, KEPT); + } +} diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index b3e1f8df21ed26b5c0fc26781274e3967091ee38..b7273dd73595e0cf74f94974de91f2e82216e224 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -77,12 +77,6 @@ impl State { } let theme = ui.theme.clone(); let row = theme.font_size * 2.0; - let window = ui.rect(Id::ROOT).unwrap_or_default(); - let height = ui.rect(id()).map_or(HEIGHT, |rect| rect[3] - rect[1]); - let [left, top] = [ - ((window[2] - WIDTH) / 2.0).max(0.0), - ((window[3] - height) / 2.0).max(0.0), - ]; let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) .contains(&NamedKey::Enter); ui.open_as( @@ -95,7 +89,7 @@ impl State { shadow: Some(theme.shadow), radius: 8.0, pad: [6.0, 6.0], - anchor: Some(Anchor::Below([left, top, left, top])), + anchor: Some(Anchor::Dialog), ..Spec::default() }, ); @@ -189,8 +183,13 @@ impl State { Page::Display => { heading(ui, &theme, "Display"); let dark = !options.light_pages; - if ui::check_box(ui, "dark-pages", "Pages appear dark in the Dark color scheme", dark) - .clicked + if ui::check_box( + ui, + "dark-pages", + "Pages appear dark in the Dark color scheme", + dark, + ) + .clicked { options.light_pages = dark; } @@ -247,12 +246,7 @@ impl State { self.author = name.to_owned(); self.color_scheme = options.color_scheme; self.light_pages = options.light_pages; - self.window.set_theme(self.color_scheme.theme()); - let appearance = self - .color_scheme - .theme() - .unwrap_or_else(|| platform::appearance(&self.window)); - self.ui.theme = crate::theme(appearance, self.light_pages); + self.follow_color_scheme(); self.save_settings(); } else if !cancel { return; @@ -270,7 +264,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) { size: [fill(), px(theme.font_size * 1.8)], text: Some(text), bold: true, - fill: Some(theme.strip), + fill: Some(ui::mix(theme.panel, theme.chip, 0.4)), radius: 4.0, pad: [8.0, 0.0], ..Spec::default() diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs new file mode 100644 index 0000000000000000000000000000000000000000..dda84c0138aedcdf97554ac69d44ec7c9bd12f68 --- /dev/null +++ b/crates/snowbound/src/rename.rs @@ -0,0 +1,201 @@ +//! Renaming in place, as VS Code renames a file and OneNote 2010 a section's tab: a field +//! over the name with it all selected, where Enter or a press elsewhere keeps the name typed +//! and Escape the old one. + +use crate::{Command, Library, State, manage::Structure}; +use onestore::{ + ExGuid, + op::{Edit, Op, PageOp}, + page::PageObject, +}; +use std::{error::Error, sync::Arc}; +use ui::{Event, Id, Spec, Theme, Ui, fill, px}; +use winit::keyboard::{Key, NamedKey}; + +/// Room between the field's edge and its text. +pub const PAD: f32 = 3.0; + +/// What is being renamed, and the name typed so far. +pub struct Renaming { + pub target: Target, + pub name: String, +} + +pub enum Target { + /// A section or group by catalog path, in its sidebar row or, `in_tab`, its tab. + Entry { + library: Arc, + path: String, + in_tab: bool, + }, + /// A page of the open section, by its title in its tab. + Page(ExGuid), +} + +impl Renaming { + /// Whether the sidebar row or tab of the section or group at `path` shows the field. + pub fn entry(&self, library: &Arc, path: &str, tab: bool) -> bool { + matches!(&self.target, Target::Entry { library: renamed, path: at, in_tab } + if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab) + } + + pub fn page(&self, space: ExGuid) -> bool { + matches!(self.target, Target::Page(renamed) if renamed == space) + } +} + +pub fn field() -> Id { + Id::ROOT.child("rename") +} + +/// The rename field, `height` tall, editing `name`: whether Enter kept or Escape dropped +/// the name typed. +pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Option { + let signal = ui::text_field( + ui, + field(), + name, + "", + Spec { + size: [fill(), px(height)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 2.0, + pad: [PAD, 0.0], + ..Spec::default() + }, + ); + signal.events.iter().find_map(|event| match event { + Event::Key { + key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)), + .. + } => Some(*key == NamedKey::Enter), + _ => None, + }) +} + +impl State { + /// Opens the rename field on `target` with its name selected. + pub(crate) fn rename(&mut self, target: Target) { + let name = match &target { + Target::Entry { path, in_tab, .. } => { + self.sidebar |= !in_tab; + let name = path.rsplit('/').next().unwrap_or_default(); + name.strip_suffix(".one").unwrap_or(name).to_owned() + } + Target::Page(space) => self + .session + .as_ref() + .and_then(|session| session.pages.iter().find(|(page, ..)| page == space)) + .map(|(_, title, _)| title.clone()) + .unwrap_or_default(), + }; + self.renaming = Some(Renaming { target, name }); + self.ui.focus_all(field()); + } + + /// Closes the rename field, renaming what it was on to the name typed where `keep`. + pub(crate) fn finish_renaming(&mut self, keep: bool) { + let Some(Renaming { target, name }) = self.renaming.take() else { + return; + }; + self.ui.set_focus(Some(crate::page())); + let name = name.trim().to_owned(); + if !keep || name.is_empty() { + return; + } + match target { + Target::Entry { library, path, .. } => { + let old = path.rsplit('/').next().unwrap_or_default(); + if name != old.strip_suffix(".one").unwrap_or(old) { + self.commands.push(Command::Structure( + library, + Structure::Rename { path, name }, + )); + } + } + Target::Page(space) => { + if let Err(error) = self.retitle(space, name) { + crate::platform::alert("Couldn't rename the page", &error.to_string()); + } + } + } + } + + /// The rename field over the section tab being renamed, of those built as `row` in the + /// tab row `bar`, where the tab's label stands. + pub(crate) fn tab_rename_field(&mut self, theme: &Theme, row: Id, bar: Id) { + let (Some(session), Some(renaming)) = (&self.session, &mut self.renaming) else { + return; + }; + let Some(tab) = session + .tabs + .iter() + .position(|tab| renaming.entry(&session.library, &tab.path, true)) + else { + return; + }; + let (Some(rect), Some(bar)) = + (self.ui.rect(ui::shell::tab_id(row, tab)), self.ui.rect(bar)) + else { + return; + }; + let width = self.ui.measure(&renaming.name)[0] + 4.0 + 2.0 * PAD; + self.ui.open( + "rename", + Spec { + flags: ui::Flags::FLOAT, + size: [px(width.max(48.0)), px(rect[3] - rect[1] - 6.0)], + position: [ + rect[0] + ui::shell::TAB_PAD - PAD - bar[0], + rect[1] + 3.0 - bar[1], + ], + ..Spec::default() + }, + ); + let kept = edit( + &mut self.ui, + theme, + &mut renaming.name, + rect[3] - rect[1] - 6.0, + ); + self.ui.close(); + if let Some(keep) = kept { + self.finish_renaming(keep); + } + } + + /// Gives page `space` of the open section the title `name`. + fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box> { + self.persist()?; + let session = self.session.as_mut().ok_or("No section is open")?; + let page = session.section.page(space)?; + let title = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(), + _ => None, + }) + .ok_or("The page has no title to rename")?; + let length = title.text.utf16_offset(title.text.text().len())?; + session.section.apply( + &self.author, + Edit { + at: crate::filetime(), + ops: vec![Op::Page { + space, + op: PageOp::Text { + text: title.id, + range: 0..length, + with: name, + }, + }], + }, + )?; + session.status = "Saving"; + session.pages = session.section.pages()?; + self.edited(vec![space]); + self.refresh() + } +} diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index 03e1856c562e5d1405e3e47027feb726f4fba69c..c8ea79036a29fef8ab33db0d46d310e769afe278 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -5,7 +5,8 @@ use super::*; use winit::window::Theme as Appearance; -/// The window in points, drawn at `SCALE` pixels per point. +/// The window in points, unless `SNOWBOUND_SCREENSHOT_SIZE` gives it as `WIDTHxHEIGHT`, +/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures. const SIZE: [f32; 2] = [1440.0, 900.0]; const SCALE: f32 = 2.0; /// A standard window's close, minimize and zoom buttons as AppKit places them beside a @@ -20,10 +21,19 @@ const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [ impl State { /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows. pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box> { - [self.config.width, self.config.height] = SIZE.map(|side| (side * SCALE) as u32); + let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") { + Ok(size) => size + .split_once('x') + .and_then(|(width, height)| Some([width.parse().ok()?, height.parse().ok()?])) + .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?, + Err(_) => SIZE, + }; + let most = self.renderer.device.limits().max_texture_dimension_2d as f32; + let scale = SCALE.min(most / size[0].max(size[1])); + [self.config.width, self.config.height] = size.map(|side| (side * scale).max(1.0) as u32); self.renderer.clear_glyph_cache(); - self.app_icon = platform::app_icon((16.0 * SCALE) as u32); - let response = self.view.scale_factor_changed(SCALE)?; + self.app_icon = platform::app_icon((16.0 * scale) as u32); + let response = self.view.scale_factor_changed(scale)?; self.respond(response); self.ui.set_focus(None); if let Some(title) = self @@ -37,9 +47,9 @@ impl State { } for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { self.window.set_theme(Some(appearance)); - self.ui.theme = theme(appearance, self.light_pages); + self.set_appearance(appearance); // The page's box sizes the view whose pictures settle. - self.layout(SIZE, SCALE)?; + self.layout(size, scale)?; let paper = self.paper(); if let Some((scene, _)) = &mut self.view.scene { scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper); @@ -47,7 +57,7 @@ impl State { // Later frames lay out with earlier frames' measurements while colours ease. let deadline = Instant::now() + std::time::Duration::from_secs(2); loop { - self.layout(SIZE, SCALE)?; + self.layout(size, scale)?; if !self.ui.wants_frame() || Instant::now() > deadline { break; } @@ -56,7 +66,7 @@ impl State { let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); self.snapshot(&path)?; #[cfg(target_os = "macos")] - window_frame(&path, appearance)?; + window_frame(&path, appearance, scale)?; } Ok(()) } @@ -65,7 +75,7 @@ impl State { /// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency /// and edges them with the window's hairline. #[cfg(target_os = "macos")] -fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box> { +fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box> { let mut reader = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?; let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?]; @@ -84,21 +94,21 @@ fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box Id { } /// The field of the box, and of the results over it. -fn field() -> Id { +pub(crate) fn field() -> Id { Id::ROOT.child("search-field") } @@ -274,7 +264,7 @@ fn sections(library: &Library) -> Vec { folder .groups .iter() - .filter(|group| !crate::library::recycle_bin(group)), + .filter(|group| !crate::library::recycle_bin(&group.path)), ); } paths @@ -678,7 +668,11 @@ impl State { }, ); } else { - let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name()); + let placeholder = format!( + "Search {} ({})", + self.search.scope.name(), + commands::shortcut(commands::Id::Search) + ); let signal = ui::text_field( &mut self.ui, field(), @@ -705,15 +699,6 @@ impl State { Ok(()) } - /// Whether `signal`, a field's, holds the command key with `letter`. - fn shortcut(&self, signal: &ui::Signal, letter: &str) -> bool { - self.view.modifiers().command - && signal.events.iter().any(|event| { - matches!(event, ui::Event::Key { key: winit::keyboard::Key::Character(key), .. } - if key.eq_ignore_ascii_case(letter)) - }) - } - fn scope_button(&mut self, theme: &Theme) { let button = self.ui.id("scope"); if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed @@ -727,6 +712,7 @@ impl State { /// OneNote's "Search In:" menu. fn scope_popup(&mut self) { let current = self.search.scope; + let find = commands::shortcut(commands::Id::Find); let mut items = vec![ ui::popup::Item { text: "Search In:", @@ -734,8 +720,8 @@ impl State { ..Default::default() }, ui::popup::Item { - text: "Find on This Page", - shortcut: FIND_KEY, + text: commands::command(commands::Id::Find).title, + shortcut: &find, ..Default::default() }, ]; @@ -800,30 +786,31 @@ impl State { self.ui.set_focus(Some(field())); } let [left, top, right, bottom] = rect; + let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]); + let width = RESULTS.max(right - left); + // The box takes the toolbar's box's place at once and widens as the dropdown comes in. + let open = self.ui.opening(results(), anchor).unwrap_or(1.0); + let field_width = right - left + (width - 8.0 - (right - left)) * open; self.ui.open_as( results(), Spec { axis: Axis::Y, - size: [px(RESULTS.max(right - left)), children()], + size: [px(width), children()], fill: Some(theme.popup), border: Some(theme.chip), shadow: Some(theme.shadow), radius: 6.0, pad: [4.0, 4.0], gap: 4.0, - anchor: Some(Anchor::Over([ - left - 4.0, - top - 4.0, - right + 4.0, - bottom + 4.0, - ])), + anchor: Some(anchor), ..Spec::default() }, ); self.ui.open( "box", Spec { - size: [fill(), px(ui::shell::TOOL)], + flags: Flags::STILL, + size: [px(field_width), px(ui::shell::TOOL)], fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, @@ -832,8 +819,12 @@ impl State { ..Spec::default() }, ); - let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name()); - let signal = ui::text_field( + let placeholder = format!( + "Search {} ({})", + self.search.scope.name(), + commands::shortcut(commands::Id::Search) + ); + ui::text_field( &mut self.ui, field(), &mut self.search.query, @@ -846,10 +837,6 @@ impl State { ); self.scope_button(theme); self.ui.close(); - if self.shortcut(&signal, "f") { - self.ui.close(); - return self.start_find(true); - } let query = self.search.query.trim().to_owned(); let found = &self.search.found; let titled = found.iter().take_while(|found| found.in_title).count(); @@ -916,7 +903,10 @@ impl State { "find", Spec { size: [fit(), px(22.0)], - text: Some(&format!("Find on page: {FIND_KEY}")), + text: Some(&format!( + "Find on page: {}", + commands::shortcut(commands::Id::Find) + )), color: Some(theme.text_dim), ..Spec::default() }, @@ -1018,7 +1008,10 @@ impl State { let label = if self.search.query.trim().is_empty() { "Find on page".to_owned() } else if count == 0 { - format!("No matches. Try {SEARCH_KEY}") + format!( + "No matches. Try {}", + commands::shortcut(commands::Id::Search) + ) } else { format!( "Match {} of {count}", @@ -1051,7 +1044,7 @@ impl State { step = Some(true); } } - let signal = ui::text_field( + ui::text_field( &mut self.ui, find_field(), &mut self.search.query, @@ -1065,10 +1058,6 @@ impl State { if std::mem::take(&mut self.search.claim) { self.ui.set_focus(Some(find_field())); } - if self.shortcut(&signal, "e") { - self.start_search(); - return Ok(()); - } if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked { self.end_search(); diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 6896c43cb39b7689b98fe28331f66ebac07dcb49..dd3826e3c65e5c84cc30db4fe60a3ef76bd85f65 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -15,6 +15,7 @@ pub struct Settings { pub sidebar: bool, /// Fonts picked from the font box, latest first. pub recent_fonts: Vec, + pub toolbar: Toolbar, /// The name edits are stored under, OneNote's User name; the account's full name until /// first saved. pub user_name: Option, @@ -25,6 +26,31 @@ pub struct Settings { pub search_scope: crate::search::Scope, } +/// What the toolbar's buttons apply from their menus' last picks. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Toolbar { + /// COLORREFs the highlighter and the font colour button apply; `None` removes the + /// highlight, or makes the colour automatic. + pub highlight: Option, + pub font_color: Option, + /// Places in OneNote's bullet and numbering libraries picked lately, latest first. + pub bullets: Vec, + pub numbering: Vec, +} + +impl Default for Toolbar { + /// Office's yellow highlighter and red font colour. + fn default() -> Self { + Self { + highlight: Some(0x00ffff), + font_color: Some(0x0000ff), + bullets: Vec::new(), + numbering: Vec::new(), + } + } +} + /// The interface's colours: the system's appearance, or one chosen in Options. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -97,6 +123,7 @@ impl crate::State { .map(|library| library.location.clone()), sidebar: self.sidebar, recent_fonts: self.recent_fonts.clone(), + toolbar: self.toolbar.clone(), user_name: Some(self.author.clone()), color_scheme: self.color_scheme, light_pages: self.light_pages, @@ -140,6 +167,12 @@ mod tests { current: Some("/notebooks/Personal".into()), sidebar: true, recent_fonts: vec!["Georgia".into()], + toolbar: Toolbar { + highlight: None, + font_color: Some(0x00ff00), + bullets: vec![3, 0], + numbering: vec![16], + }, user_name: Some("Snowbound Test".into()), color_scheme: ColorScheme::Dark, light_pages: true, @@ -155,6 +188,11 @@ mod tests { ..Settings::default() } ); + // A picked "No color" stays picked; what was never picked keeps Office's default. + std::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap(); + let toolbar = Settings::load(&path).toolbar; + assert_eq!(toolbar.highlight, None); + assert_eq!(toolbar.font_color, Toolbar::default().font_color); std::fs::remove_dir_all(&directory).unwrap(); } } diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 9d852744496672b35aa240ffe4e83336e6ae067b..bc5a740cab7dbb8b202396d91547cd6b4367fdf4 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -1,26 +1,25 @@ //! OneNote 2010's navigation bar: the open notebooks with their sections and section //! groups, down the window's left, beside the section tabs. -use crate::{art, library::Library, menus::Target, section_color}; +use crate::{ + art, + library::Library, + menus::{Dragged, Target}, + rename, section_color, +}; use notebook::discover::{Folder, SectionState}; use std::{collections::HashSet, sync::Arc}; -use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; -use winit::keyboard::{Key, NamedKey}; +use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; pub const WIDTH: f32 = 220.0; -/// The collapsed sidebar: a rail as wide as its button, which stays where it is as the -/// sidebar opens. -pub const RAIL: f32 = crate::FRAME + ui::shell::TOOL + 2.0; +/// The square the sidebar's button stands in at the start of the section tabs. +const RAIL: f32 = crate::TAB_ROW; const ROW: f32 = 24.0; /// How far each level of the tree sits inside its parent. const INDENT: f32 = 16.0; /// What the sidebar was asked to do. pub enum Action { - /// Expands or collapses the sidebar. - Toggle, - /// Shows a notebook where it was left. - Notebook(usize), /// Opens a notebook's section by catalog path. Open { notebook: usize, @@ -36,18 +35,8 @@ pub enum Action { Menu(Target, [f32; 2]), /// Ends renaming, with the name typed or without. Renamed(bool), -} - -/// A section or group being renamed in place, and the name typed so far. -pub struct Renaming { - pub library: Arc, - pub path: String, - pub name: String, -} - -/// The field a row being renamed shows. -pub fn rename_field() -> Id { - Id::ROOT.child("rename") + /// Explains why a section or group can't be read: its name and the reason. + Unavailable(String, String), } /// Names a notebook's or group's rows for folding them. @@ -61,12 +50,35 @@ pub struct Tree<'a> { /// The open section's notebook index and catalog path. open: Option<(usize, &'a str)>, folded: &'a HashSet, - renaming: Option<&'a mut Renaming>, + renaming: Option<&'a mut rename::Renaming>, action: Option, /// Each section and group row: where it lies, and what it is. rows: Vec, /// The section or group held down, which a drag moves. held: Option, + /// The row dragged, which the tree leaves out for a gap where it would land. + lifted: Option<(&'a Entry, &'a Landing)>, + /// The dragged row as it was built, for drawing it over the others. + ghost: Option, + /// Where the gap the dragged row would land in stands. + gap: Option<[f32; 4]>, +} + +/// Where a dragged row would land: before a row of a folder or at its end, or into a group. +#[derive(Clone, PartialEq)] +pub struct Landing { + pub folder: String, + pub before: Option, + pub into: bool, +} + +/// The dragged row's look, kept to draw it over the others. +struct Ghost { + label: String, + icon: Leading, + depth: u32, + dim: bool, + selected: Option<[f32; 4]>, } impl<'a> Tree<'a> { @@ -74,7 +86,7 @@ impl<'a> Tree<'a> { theme: &'a Theme, open: Option<(usize, &'a str)>, folded: &'a HashSet, - renaming: Option<&'a mut Renaming>, + renaming: Option<&'a mut rename::Renaming>, ) -> Self { Self { theme, @@ -84,6 +96,9 @@ impl<'a> Tree<'a> { action: None, rows: Vec::new(), held: None, + lifted: None, + ghost: None, + gap: None, } } } @@ -95,18 +110,26 @@ pub struct Entry { pub path: String, pub group: bool, pub rect: [f32; 4], + /// The row's box, which a drag draws over the others. + pub id: Id, } impl Entry { fn folder(&self) -> &str { self.path.rsplit_once('/').map_or("", |(folder, _)| folder) } + + /// Whether this is the row `lifted`, or one inside it. + fn within(&self, lifted: &Entry) -> bool { + self.notebook == lifted.notebook + && (self.path == lifted.path || self.path.starts_with(&format!("{}/", lifted.path))) + } } /// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with /// the open section marked. fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32, rows: bool) { - let (theme, open, folded) = (tree.theme, tree.open, tree.folded); + let (theme, folded) = (tree.theme, tree.folded); ui.open( "header", Spec { @@ -116,9 +139,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 ..Spec::default() }, ); - if ui::shell::tool_button(ui, "toggle", art::NOTEBOOK, theme.text, false).clicked { - tree.action = Some(Action::Toggle); - } + // The button's square, which floats over this place from the section tabs. + ui.leaf( + "toggle", + Spec { + size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], + ..Spec::default() + }, + ); ui.leaf( "title", Spec { @@ -148,7 +176,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 let (row, fold) = tree_row( ui, tree, - ("notebook", index), + ui.id(("notebook", index)), Row { label: &library.name, icon: Leading::Icon(art::NOTEBOOK), @@ -159,16 +187,18 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 renamed: false, }, ); - if fold { + if row.clicked || fold { tree.action = Some(Action::Fold(key)); - } else if row.clicked && open.is_none_or(|(notebook, _)| notebook != index) { - tree.action = Some(Action::Notebook(index)); } if let Some(point) = row.context { tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point)); } - if unfolded && let Some(catalog) = library.catalog() { + if let Some(catalog) = library.catalog() + && unfolding(ui, ("notebook rows", index), unfolded) + { folder(ui, tree, library, index, catalog, 1); + ui.close(); + ui.close(); } } ui.close(); @@ -192,7 +222,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 let (row, _) = tree_row( ui, tree, - part, + ui.id(part), Row { label, icon: Leading::Icon(icon), @@ -224,9 +254,9 @@ fn folder( .filter(|(index, _)| *index == notebook) .map(|(_, path)| path); let renamed = |tree: &Tree, path: &str| { - tree.renaming.as_ref().is_some_and(|renaming| { - Arc::ptr_eq(&renaming.library, library) && renaming.path == path - }) + tree.renaming + .as_ref() + .is_some_and(|renaming| renaming.entry(library, path, false)) }; for section in &folder.sections { let (name, color, readable) = match §ion.state { @@ -241,28 +271,29 @@ fn folder( false, ), }; + gap(ui, tree, &folder.path, Some(§ion.path)); let renaming = renamed(tree, §ion.path); - let (row, _) = tree_row( - ui, - tree, - ("section", notebook, §ion.path), - Row { - label: &name, - icon: Leading::Swatch(section_color(color)), - depth, - dim: !readable, - fold: None, - selected: (open == Some(section.path.as_str())).then_some(section_color(color)), - renamed: renaming, - }, - ); + let id = ui.id(("section", notebook, §ion.path)); + let row = Row { + label: &name, + icon: Leading::Section(section_color(color)), + depth, + dim: !readable, + fold: None, + selected: (open == Some(section.path.as_str())).then_some(section_color(color)), + renamed: renaming, + }; + if lifts(tree, notebook, §ion.path, &row) { + continue; + } + let (row, _) = tree_row(ui, tree, id, row); if row.clicked && readable && open != Some(section.path.as_str()) { tree.action = Some(Action::Open { notebook, path: section.path.clone(), }); } - entry(ui, tree, notebook, §ion.path, false, &row); + entry(ui, tree, notebook, §ion.path, id, false, &row); if let Some(point) = row.context { let target = Target::Section { library: Arc::clone(library), @@ -273,50 +304,200 @@ fn folder( } for group in &folder.groups { // OneNote lists deleted sections under its own command, not as a group. - if crate::library::recycle_bin(group) { + if crate::library::recycle_bin(&group.path) { continue; } let key = fold_key(library, &group.path); let unfolded = !tree.folded.contains(&key); let name = group.path.rsplit('/').next().unwrap_or_default(); + gap(ui, tree, &folder.path, Some(&group.path)); let renaming = renamed(tree, &group.path); - let (row, fold) = tree_row( + let id = ui.id(("group", notebook, &group.path)); + let row = Row { + label: name, + icon: Leading::Icon(art::SECTION_GROUP), + depth, + dim: false, + fold: Some(unfolded), + selected: None, + renamed: renaming, + }; + // A dragged group's rows fold away beneath it. + let lifted = lifts(tree, notebook, &group.path, &row); + if !lifted { + let (row, fold) = tree_row(ui, tree, id, row); + if row.clicked || fold { + tree.action = Some(Action::Fold(key)); + } + entry(ui, tree, notebook, &group.path, id, true, &row); + if let Some(point) = row.context { + let target = Target::Group { + library: Arc::clone(library), + path: group.path.clone(), + }; + tree.action = Some(Action::Menu(target, point)); + } + } + if unfolding( ui, - tree, - ("group", notebook, &group.path), - Row { - label: name, - icon: Leading::Icon(art::SECTION_GROUP), - depth, - dim: false, - fold: Some(unfolded), - selected: None, - renamed: renaming, - }, - ); - if row.clicked || fold { - tree.action = Some(Action::Fold(key)); - } - entry(ui, tree, notebook, &group.path, true, &row); - if let Some(point) = row.context { - let target = Target::Group { - library: Arc::clone(library), - path: group.path.clone(), - }; - tree.action = Some(Action::Menu(target, point)); - } - if unfolded { + ("group rows", notebook, &group.path), + unfolded && !lifted, + ) { self::folder(ui, tree, library, notebook, group, depth + 1); + ui.close(); + ui.close(); } } + gap(ui, tree, &folder.path, None); + for entry in &folder.unavailable { + if crate::library::recycle_bin(&entry.path) { + continue; + } + let name = entry.path.rsplit('/').next().unwrap_or_default(); + let name = if entry.group { + name.to_owned() + } else { + crate::library::section_name(&entry.path, &None) + }; + let (row, _) = tree_row( + ui, + tree, + ui.id(("unavailable", notebook, &entry.path)), + Row { + label: &name, + icon: if entry.group { + Leading::Icon(art::SECTION_GROUP) + } else { + Leading::Section(section_color(None)) + }, + depth, + dim: true, + fold: None, + selected: None, + renamed: false, + }, + ); + if row.clicked { + tree.action = Some(Action::Unavailable(name, entry.error.clone())); + } + } +} + +/// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a +/// group's middle, into it; above or below a row's middle, before or after it in its +/// folder; into another folder, at its end. +fn landing_at(rows: &[Entry], lifted: &Entry, y: f32) -> Option { + let candidates: Vec<&Entry> = rows + .iter() + .filter(|row| row.notebook == lifted.notebook && !row.within(lifted)) + .collect(); + let under = candidates + .iter() + .find(|row| y >= row.rect[1] && y < row.rect[3]) + .or_else(|| candidates.last().filter(|row| y >= row.rect[3]))?; + let [top, bottom] = [under.rect[1], under.rect[3]]; + let quarter = (bottom - top) / 4.0; + if under.group && y > top + quarter && y < bottom - quarter { + return Some(Landing { + folder: under.path.clone(), + before: None, + into: true, + }); + } + let folder = under.folder(); + let before = if y < (top + bottom) / 2.0 { + Some(under.path.clone()) + } else { + candidates + .iter() + .skip_while(|row| row.path != under.path) + .skip(1) + .find(|row| !row.within(under)) + .filter(|row| row.folder() == folder) + .map(|row| row.path.clone()) + }; + Some(Landing { + folder: folder.to_owned(), + before: before.filter(|_| folder == lifted.folder()), + into: false, + }) +} + +/// Whether the row of `path` in `notebook` is the one dragged, which the tree leaves out, +/// keeping its look to draw it over the others. +fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { + let lifted = tree + .lifted + .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path); + if lifted { + tree.ghost = Some(Ghost { + label: row.label.to_owned(), + icon: row.icon, + depth: row.depth, + dim: row.dim, + selected: row.selected, + }); + } + lifted +} + +/// The gap before the row of `before` in `folder`, or at its end, which opens where a +/// dragged row would land and closes where it no longer would. +fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) { + let Some((_, landing)) = tree.lifted else { + return; + }; + let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before; + let id = ui.id(("gap", before)); + let height = ui.animate(id, if here { ROW } else { 0.0 }); + if here { + tree.gap = ui.rect(id); + } + if height > 0.0 { + ui.leaf( + ("gap", before), + Spec { + size: [fill(), px(height)], + ..Spec::default() + }, + ); + } +} + +/// Opens the boxes a notebook's or group's rows ease open and shut in, where they show: +/// the caller builds the rows and closes both. +fn unfolding(ui: &mut Ui, part: impl std::hash::Hash, unfolded: bool) -> bool { + let outer = ui.id(part); + let rows = ui + .rect(outer.child("rows")) + .map_or(0.0, |rect| rect[3] - rect[1]); + let height = ui.animate(outer, if unfolded { rows } else { 0.0 }); + if !unfolded && height == 0.0 { + return false; + } + ui.open_as( + outer, + Spec { + flags: Flags::CLIP, + axis: Axis::Y, + size: [fill(), px(height)], + ..Spec::default() + }, + ); + ui.open( + "rows", + Spec { + axis: Axis::Y, + size: [fill(), ui::children()], + ..Spec::default() + }, + ); + true } -/// Records a section or group row for dragging. -fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) { - let Some(rect) = ui - .rect(ui.id(("section", notebook, path))) - .or_else(|| ui.rect(ui.id(("group", notebook, path)))) - else { +/// Records a section or group row, built as `id`, for dragging. +fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, id: Id, group: bool, row: &Signal) { + let Some(rect) = ui.rect(id) else { return; }; let entry = Entry { @@ -324,6 +505,7 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row path: path.to_owned(), group, rect, + id, }; if row.dragging { tree.held = Some(entry.clone()); @@ -331,10 +513,11 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row tree.rows.push(entry); } +#[derive(Clone, Copy)] enum Leading { Icon(&'static [&'static str]), - /// A section's colour, as OneNote's section icon shows it. - Swatch([f32; 4]), + /// A section's tab, drawn in its colour. + Section([f32; 4]), } struct Row<'a> { @@ -351,13 +534,13 @@ struct Row<'a> { } /// One row of the tree: its signal, and whether its fold arrow was clicked. -fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) { +fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { let theme = tree.theme; let lit = row .selected .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35)); - let id = ui.open( - part, + ui.open_as( + id, Spec { flags: Flags::CLICKABLE, size: [fill(), px(ROW)], @@ -365,69 +548,31 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)), radius: 4.0, pad: [6.0 + INDENT * row.depth as f32, 0.0], - gap: 6.0, + // The field's text stands where the label did. + gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 }, ..Spec::default() }, ); let color = if row.dim { theme.text_dim } else { theme.text }; - match row.icon { - Leading::Icon(icon) => { - ui.leaf( - "icon", - Spec { - size: [px(16.0), px(ROW)], - icon: Some(icon), - color: Some(color), - ..Spec::default() - }, - ); - } - Leading::Swatch(swatch) => { - ui.open( - "swatch", - Spec { - size: [px(16.0), px(ROW)], - pad: [3.0, 6.0], - ..Spec::default() - }, - ); - ui.leaf( - "color", - Spec { - size: [fill(), fill()], - fill: Some(theme.section(swatch).frame[0]), - border: Some(theme.section(swatch).edge), - radius: 2.0, - ..Spec::default() - }, - ); - ui.close(); - } - } + let (icon, [red, green, blue, _]) = match row.icon { + Leading::Icon(icon) => (icon, theme.text), + Leading::Section(section) => (art::SECTION, theme.section(section).accent), + }; + // Coloured art keeps its colours, so a dim row fades its icon. + let alpha = if row.dim { 0.5 } else { 1.0 }; + ui.leaf( + "icon", + Spec { + size: [px(16.0), px(ROW)], + icon: Some(icon), + color: Some([red, green, blue, alpha]), + ..Spec::default() + }, + ); match (row.renamed, tree.renaming.as_deref_mut()) { (true, Some(renaming)) => { - let field = ui::text_field( - ui, - rename_field(), - &mut renaming.name, - "", - Spec { - size: [fill(), px(ROW - 4.0)], - fill: Some(theme.base), - border: Some(theme.accent), - radius: 3.0, - pad: [4.0, 0.0], - ..Spec::default() - }, - ); - for event in &field.events { - if let Event::Key { - key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)), - .. - } = event - { - tree.action = Some(Action::Renamed(*key == NamedKey::Enter)); - } + if let Some(keep) = rename::edit(ui, theme, &mut renaming.name, ROW - 4.0) { + tree.action = Some(Action::Renamed(keep)); } } _ => { @@ -464,14 +609,14 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) } impl crate::State { - /// The sidebar left of the section tabs, easing open and shut. - pub(crate) fn sidebar(&mut self, theme: &Theme) { + /// The sidebar left of the section tabs, easing open and shut; returns its width. + pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 { if self.temporary { - return; + return 0.0; } let width = self.ui.animate( self.ui.id("sidebar"), - if self.sidebar { WIDTH } else { RAIL }, + if self.sidebar { WIDTH } else { 0.0 }, ); self.ui.open( "sidebar", @@ -500,39 +645,43 @@ impl crate::State { Some((index, session.tabs[session.tab].path.as_str())) }); let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); - // The collapsed rail shows only its button. + tree.lifted = self + .drag + .as_ref() + .filter(|drag| drag.lifted) + .and_then(|drag| match &drag.what { + Dragged::Entry { entry, landing, .. } => Some((entry, landing)), + _ => None, + }); sidebar( &mut self.ui, &mut tree, &self.notebooks, crate::TAB_ROW, - width > RAIL + 0.5, + width > 0.5, ); let Tree { - action, rows, held, .. + action, + rows, + mut held, + ghost, + gap, + .. } = tree; let corner = self.ui.rect(rows_id).unwrap_or_default(); - self.drag_entries(held, &rows, corner, theme); + let found = ghost.is_some(); + let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner); + held = held.or(ghost_held); + self.drag_entries(held, &rows, gap, settled || !found, corner, theme); self.ui.close(); self.ui.close(); match action { - Some(Action::Toggle) => { - self.sidebar = !self.sidebar; - self.save_settings(); - } Some(Action::Fold(key)) => { if !self.folded.remove(&key) { self.folded.insert(key); } } - Some(Action::Notebook(index)) => { - let library = Arc::clone(&self.notebooks[index]); - if let Some(path) = library.first_section() { - self.commands - .push(crate::Command::OpenSection(library, path)); - } - } - Some(Action::Open { notebook, path }) => { + Some(Action::Open { notebook, path }) if !self.dragged() => { let library = Arc::clone(&self.notebooks[notebook]); self.commands .push(crate::Command::OpenSection(library, path)); @@ -541,120 +690,208 @@ impl crate::State { self.menu = Some((target, point)); self.ui.open_popup(crate::menus::id()); } - Some(Action::Renamed(keep)) => { - if let Some(renaming) = self.renaming.take() - && keep - { - let name = renaming.name.trim().to_owned(); - let old = renaming.path.rsplit('/').next().unwrap_or_default(); - if !name.is_empty() && name != old.strip_suffix(".one").unwrap_or(old) { - self.commands.push(crate::Command::Structure( - renaming.library, - crate::manage::Structure::Rename { - path: renaming.path, - name, - }, - )); - } - } - self.ui.set_focus(Some(crate::page())); - } + Some(Action::Renamed(keep)) => self.finish_renaming(keep), Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), Some(Action::Options) => self.open_options(), - None => {} + Some(Action::Unavailable(name, error)) => crate::platform::alert( + &format!("Can't read \u{201c}{name}\u{201d}"), + &format!("{error}\n\nIt appears here once you have access."), + ), + Some(Action::Open { .. }) | None => {} } + width } - /// Moves a section or group by dragging its row: onto a group's middle it moves into the - /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it - /// takes that place; between rows of another folder it moves there, last. `corner` is - /// the rows' box. + /// The sidebar's button in its square at the window's edge, and the room the section + /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar + /// eases open and shut. + pub(crate) fn sidebar_button(&mut self, theme: &Theme, width: f32) { + if self.temporary { + return; + } + // Past the tab row's padding and the frame's corner, the tabs start where the square + // ends; the room shrinks on the sidebar's own easing, so the tabs ease with it. + let room = RAIL - crate::FRAME - self.rounding(); + self.ui.leaf( + "rail", + Spec { + size: [px(room * (1.0 - width / WIDTH)), px(1.0)], + ..Spec::default() + }, + ); + self.ui.open( + "toggle", + Spec { + flags: Flags::FLOAT, + size: [px(RAIL), px(RAIL)], + position: [-width, 0.0], + pad: [(RAIL - ui::shell::TOOL) / 2.0; 2], + ..Spec::default() + }, + ); + if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, false).clicked + { + self.sidebar = !self.sidebar; + self.save_settings(); + } + self.ui.close(); + } + + /// The dragged row, `ghost`, drawn over the others in the rows' box `corner`: following + /// the pointer, or once let go easing into the `gap` it lands in, or onto the group it + /// lands in among `rows`. Returns it while held, and whether it stands in its place. + fn lifted_row( + &mut self, + theme: &Theme, + ghost: Option, + gap: Option<[f32; 4]>, + rows: &[Entry], + corner: [f32; 4], + ) -> (Option, bool) { + let ( + Some(ghost), + Some( + drag @ crate::menus::Drag { + what: Dragged::Entry { entry, landing, .. }, + .. + }, + ), + ) = (ghost, &self.drag) + else { + return (None, true); + }; + let key = self.ui.id("lifted"); + let (top, settled) = if drag.live() { + let top = drag.corner(self.pointer)[1] - corner[1]; + (self.ui.hold(key, top), false) + } else { + let place = gap + .or_else(|| { + rows.iter() + .find(|row| landing.into && row.path == landing.folder) + .map(|row| row.rect) + }) + .unwrap_or(entry.rect); + let target = place[1] - corner[1]; + let top = self.ui.animate(key, target); + (top, (top - target).abs() < 0.5) + }; + let entry = entry.clone(); + self.ui.open( + "lifted", + Spec { + flags: Flags::FLOAT, + size: [px(entry.rect[2] - entry.rect[0]), px(ROW)], + position: [entry.rect[0] - corner[0], top], + fill: Some(theme.base), + shadow: Some([0.0, 0.0, 0.0, 0.3]), + radius: 4.0, + ..Spec::default() + }, + ); + let mut tree = Tree::new(theme, None, &self.folded, None); + let (row, _) = tree_row( + &mut self.ui, + &mut tree, + entry.id, + Row { + label: &ghost.label, + icon: ghost.icon, + depth: ghost.depth, + dim: ghost.dim, + fold: None, + selected: ghost.selected, + renamed: false, + }, + ); + self.ui.close(); + (row.dragging.then_some(entry), settled) + } + + /// Moves a section or group by dragging its row, `held`, among `rows` in the rows' box + /// `corner`: the others open a `gap` where it would land. Onto a group's middle it moves + /// into the group, as OneNote moves one dropped on a group's tab; between rows of its + /// folder it takes that place; into another folder it moves there, last. The drag ends + /// once the row, let go, is `settled`. fn drag_entries( &mut self, held: Option, rows: &[Entry], + gap: Option<[f32; 4]>, + settled: bool, corner: [f32; 4], theme: &Theme, ) { - let dragged = match (&self.drag, &held) { - (_, Some(entry)) => Some(entry.clone()), - (Some(crate::menus::Drag::Entry(entry)), None) => Some(entry.clone()), - _ => None, + let mine = |what: &Dragged| matches!(what, Dragged::Entry { .. }); + self.settle(mine, settled); + let held = held.map(|entry| { + let rect = entry.rect; + let home = Landing { + folder: entry.folder().to_owned(), + before: rows + .iter() + .skip_while(|row| row.path != entry.path) + .skip(1) + .find(|row| !row.within(&entry) && row.notebook == entry.notebook) + .filter(|row| row.folder() == entry.folder()) + .map(|row| row.path.clone()), + into: false, + }; + let what = Dragged::Entry { + landing: home.clone(), + home, + entry, + }; + (what, rect) + }); + let dropped = self.follow_drag(held, mine); + let pointer = self.pointer; + let Some( + drag @ crate::menus::Drag { + what: Dragged::Entry { .. }, + .. + }, + ) = &mut self.drag + else { + return; }; - if let Some(entry) = &held { - self.drag = Some(crate::menus::Drag::Entry(entry.clone())); - } else if matches!(self.drag, Some(crate::menus::Drag::Entry(_))) { - self.drag = None; + let live = drag.live(); + let middle = drag.corner(pointer)[1] + ROW / 2.0; + let Dragged::Entry { + entry, + landing, + home, + } = &mut drag.what + else { + return; + }; + if drag.cancelled { + *landing = home.clone(); + } else if live { + // Where the rows stand with no gap open, so the gap follows the dragged row + // rather than the rows it pushed aside. + let closed: Vec = rows + .iter() + .map(|row| { + let mut row = row.clone(); + if let Some([_, top, _, bottom]) = gap + && row.rect[1] >= bottom - 0.5 + { + row.rect[1] -= bottom - top; + row.rect[3] -= bottom - top; + } + row + }) + .collect(); + if let Some(found) = landing_at(&closed, entry, middle) { + *landing = found; + } } - let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else { - return; - }; - let inside = |entry: &Entry| { - entry.path == dragged.path || entry.path.starts_with(&format!("{}/", dragged.path)) - }; - let candidates: Vec<&Entry> = rows - .iter() - .filter(|entry| entry.notebook == dragged.notebook && !inside(entry)) - .collect(); - let Some(under) = candidates - .iter() - .find(|entry| y >= entry.rect[1] && y < entry.rect[3]) - .or_else(|| candidates.last().filter(|entry| y >= entry.rect[3])) - else { - return; - }; - let [top, bottom] = [under.rect[1], under.rect[3]]; - let quarter = (bottom - top) / 4.0; - let into = under.group && y > top + quarter && y < bottom - quarter; - let (folder, before) = if into { - (under.path.as_str(), None) - } else if y < (top + bottom) / 2.0 { - (under.folder(), Some(under.path.as_str())) - } else { - let next = candidates - .iter() - .skip_while(|entry| entry.path != under.path) - .nth(1) - .filter(|entry| entry.folder() == under.folder()); - (under.folder(), next.map(|entry| entry.path.as_str())) - }; - let change = if folder == dragged.folder() { - if into { - return; - } - // The folder's order with the dragged entry where it was dropped. - let mut paths: Vec = candidates - .iter() - .filter(|entry| entry.folder() == folder) - .map(|entry| entry.path.clone()) - .collect(); - let at = before - .and_then(|before| paths.iter().position(|path| path == before)) - .unwrap_or(paths.len()); - paths.insert(at, dragged.path.clone()); - let current: Vec<&str> = rows - .iter() - .filter(|entry| entry.notebook == dragged.notebook && entry.folder() == folder) - .map(|entry| entry.path.as_str()) - .collect(); - if paths.iter().map(String::as_str).eq(current) { - return; - } - crate::manage::Structure::Reorder { - folder: folder.to_owned(), - paths, - } - } else { - crate::manage::Structure::Move { - path: dragged.path.clone(), - folder: folder.to_owned(), - } - }; - if held.is_some() { + if live && landing.into { let accent = theme.accent; - if into { - let [left, top, right, bottom] = under.rect; + if let Some(group) = rows.iter().find(|row| row.path == landing.folder) { + let [left, top, right, bottom] = group.rect; self.ui.mark( [ left - corner[0], @@ -665,28 +902,38 @@ impl crate::State { [accent[0], accent[1], accent[2], 0.3], 4.0, ); - } else { - let line = if before == Some(under.path.as_str()) { - top - } else { - bottom - } - corner[1]; - self.ui.mark( - [ - under.rect[0] - corner[0], - line - 1.5, - under.rect[2] - corner[0], - line + 1.5, - ], - accent, - 1.5, - ); + } + } + if !dropped || landing == home { + return; + } + let change = if landing.into || landing.folder != entry.folder() { + crate::manage::Structure::Move { + path: entry.path.clone(), + folder: landing.folder.clone(), } } else { - let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]); - self.commands - .push(crate::Command::Structure(library, change)); - } + // The folder's order with the dragged entry where it was dropped. + let mut paths: Vec = rows + .iter() + .filter(|row| row.notebook == entry.notebook && row.folder() == landing.folder) + .filter(|row| !row.within(entry)) + .map(|row| row.path.clone()) + .collect(); + let at = landing + .before + .as_ref() + .and_then(|before| paths.iter().position(|path| path == before)) + .unwrap_or(paths.len()); + paths.insert(at, entry.path.clone()); + crate::manage::Structure::Reorder { + folder: landing.folder.clone(), + paths, + } + }; + let library = Arc::clone(&self.notebooks[entry.notebook]); + self.commands + .push(crate::Command::Structure(library, change)); } /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, @@ -745,13 +992,64 @@ impl crate::State { /// The window below the title bar on the first run, or once every notebook is closed: /// what is missing, and the two ways to start. pub(crate) fn welcome(&mut self, theme: &Theme) { + let id = self.ui.id("welcome"); + self.notice( + theme, + id, + theme.base, + "No notebooks open", + vec![ + ( + "new", + art::PLUS, + "New Notebook", + crate::Command::NewNotebook, + ), + ( + "open", + art::NOTEBOOK, + "Open Existing", + crate::Command::OpenNotebook, + ), + ], + ); + } + + /// The page's place while notebook `library` has no sections, as OneNote 2010 shows + /// it, with a button to add one. + pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc) { + let new = crate::Command::Structure( + library, + crate::manage::Structure::NewSection { + folder: String::new(), + }, + ); + self.notice( + theme, + crate::page(), + theme.strip, + "No sections in this notebook", + vec![("new", art::PLUS, "New Section", new)], + ); + } + + /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each + /// a part, an icon, a label and what it does. + fn notice( + &mut self, + theme: &Theme, + id: ui::Id, + background: [f32; 4], + title: &str, + buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>, + ) { const BUTTON: [f32; 2] = [200.0, 32.0]; - let [left, top, right, bottom] = self.ui.rect(self.ui.id("welcome")).unwrap_or_default(); - self.ui.open( - "welcome", + let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default(); + self.ui.open_as( + id, Spec { size: [fill(), fill()], - fill: Some(theme.base), + fill: Some(background), ..Spec::default() }, ); @@ -773,26 +1071,13 @@ impl crate::State { "title", Spec { size: [fill(), px(28.0)], - text: Some("No notebooks open"), + text: Some(title), center: true, ..Spec::default() }, ); let mut chosen = None; - for (part, icon, label, command) in [ - ( - "new", - art::PLUS, - "New Notebook", - crate::Command::NewNotebook, - ), - ( - "open", - art::NOTEBOOK, - "Open Existing", - crate::Command::OpenNotebook, - ), - ] { + for (part, icon, label, command) in buttons { let button = self.ui.leaf( part, Spec { @@ -817,3 +1102,43 @@ impl crate::State { self.ui.close(); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn row(path: &str, group: bool, top: f32) -> Entry { + Entry { + notebook: 0, + path: path.to_owned(), + group, + rect: [0.0, top, 200.0, top + ROW], + id: Id::ROOT.child(path), + } + } + + /// A dragged row lands before or after a row by its middle, into a group by the group's + /// middle half, and at the end of another folder. + #[test] + fn a_dragged_row_lands_between_rows_into_groups_and_last_in_other_folders() { + let rows = [ + row("A.one", false, 0.0), + row("B.one", false, 24.0), + row("G", true, 48.0), + row("G/C.one", false, 72.0), + ]; + let lifted = row("Z.one", false, 200.0); + let landing = |y| landing_at(&rows, &lifted, y).unwrap(); + let at = |folder: &str, before: Option<&str>, into| Landing { + folder: folder.to_owned(), + before: before.map(str::to_owned), + into, + }; + assert!(landing(4.0) == at("", Some("A.one"), false)); + assert!(landing(20.0) == at("", Some("B.one"), false)); + assert!(landing(60.0) == at("G", None, true)); + assert!(landing(50.0) == at("", Some("G"), false)); + assert!(landing(80.0) == at("G", None, false)); + assert!(landing(500.0) == at("G", None, false)); + } +} diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index e5552cd36be5d46d682adf1c2cd3d23e4351218a..810ddea1eb98be9acd563a87962d0a3319e9149d 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -418,7 +418,7 @@ impl crate::State { .chain(editor.caret_outline()) .filter(|outline| !outline.title) .all(canvas::editor::TextOutline::is_empty); - if !blank || session.conflict(space).is_some() || self.dismissed.contains(&space) { + if !blank || session.read_only() || self.dismissed.contains(&space) { self.templates = View::Strip; return; } diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index b0cbb85a062fb850b9aa5fe501b4c32514b7b6d0..2d2b1bcbd384d5286767c1de30fc1a352bfe2642 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -65,10 +65,176 @@ fn main() { render(panels, 5, &path); println!("{path}"); } + let items = font_items(); + let fonts = Id::ROOT.child("fonts"); + let context = Id::ROOT.child("context"); + let options = Id::ROOT.child("options"); + let search = Id::ROOT.child("search"); + let strips: [(&str, Id, &Build<'_>); 4] = [ + ("menu", context, &|ui, _| { + ui::popup::menu(ui, context, Anchor::Point([140.0, 70.0]), &items[..8], None); + }), + ("combo", fonts, &|ui, [combo, _]| { + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + ui::popup::menu(ui, fonts, anchor, &items, Some("Calibri")); + }), + ("search", search, &|ui, [combo, _]| { + results(ui, search, combo) + }), + ("dialog", options, &|ui, _| dialog(ui, options)), + ]; + for (motion, id, build) in strips { + let panels = opening_and_closing(&theme, &device, &queue, &mut renderer, id, build); + let path = format!("/tmp/ui-motion-{motion}-{name}.png"); + render(panels, 6, &path); + println!("{path}"); + } } measure(); } +/// Builds a frame's popup, given the ids of the combo and the split button's arrow. +type Build<'a> = dyn Fn(&mut Ui, [Id; 2]) + 'a; + +/// Popup `id` as `build` builds it, every 16 ms from the frame it opens until it has +/// closed again; no frame is asked for once each settles. +fn opening_and_closing( + theme: &Theme, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut draw::Renderer, + id: Id, + build: &Build<'_>, +) -> Vec> { + let mut scene = Scene { + ui: Ui::new(theme.clone(), Duration::from_millis(500)), + start: Instant::now(), + frames: 0, + }; + scene.frame(|_, _| {}); + scene.frame(|ui, ids| { + ui.open_popup(id); + build(ui, ids); + }); + let mut panels = vec![paint(device, queue, renderer, &scene.ui)]; + for _ in 0..17 { + scene.frame(build); + panels.push(paint(device, queue, renderer, &scene.ui)); + } + assert!(!scene.ui.wants_frame(), "settled open"); + scene.ui.close_popup(id); + for _ in 0..12 { + scene.frame(build); + panels.push(paint(device, queue, renderer, &scene.ui)); + } + assert!(!scene.ui.wants_frame(), "settled closed"); + panels +} + +/// A search's dropdown over the box at `combo`, as the app's search box opens: its field +/// holds the box's place and widens while the panel comes in around it. +fn results(ui: &mut Ui, id: Id, combo: Id) { + if !ui.popup_open(id) { + return; + } + let theme = ui.theme.clone(); + let [left, top, right, bottom] = ui.rect(combo).unwrap_or_default(); + let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]); + let width = 300.0; + let open = ui.opening(id, anchor).unwrap_or(1.0); + ui.open_as( + id, + Spec { + axis: ui::Axis::Y, + size: [px(width), ui::children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 6.0, + pad: [4.0, 4.0], + gap: 4.0, + anchor: Some(anchor), + ..Spec::default() + }, + ); + ui.leaf( + "box", + Spec { + flags: ui::Flags::STILL, + size: [ + px(right - left + (width - 8.0 - (right - left)) * open), + px(bottom - top), + ], + text: Some("Search All Notebooks (⌘E)"), + color: Some(theme.text_dim), + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + for (index, page) in ["Meeting notes", "Quarterly planning", "Reading list"] + .iter() + .enumerate() + { + ui.leaf( + index, + Spec { + size: [fill(), px(22.0)], + text: Some(page), + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + } + ui.close(); +} + +/// A small dialog, as Options opens over the window. +fn dialog(ui: &mut Ui, id: Id) { + if !ui.popup_open(id) { + return; + } + let theme = ui.theme.clone(); + ui.open_as( + id, + Spec { + axis: ui::Axis::Y, + size: [px(260.0), ui::children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [14.0, 12.0], + gap: 8.0, + anchor: Some(Anchor::Dialog), + ..Spec::default() + }, + ); + ui.leaf( + "title", + Spec { + size: [fill(), ui::fit()], + text: Some("Options"), + bold: true, + ..Spec::default() + }, + ); + ui.leaf( + "body", + Spec { + size: [fill(), ui::fit()], + text: Some("These choices apply to every notebook, and are kept when OK is chosen."), + overflow: ui::Overflow::Wrap, + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui::button(ui, "ok", "OK"); + ui.close(); +} + /// Frames a change shows at: before it, then each of the 150 ms it takes. const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10]; @@ -344,6 +510,7 @@ impl Scene { art!("icons/font-color"), Some(draw::srgb(0xe8, 0x3a, 0x30)), false, + Id::ROOT.child("font color"), ); ui.close(); ui.close(); @@ -568,7 +735,7 @@ fn highlight(scene: &mut Scene) { let id = Id::ROOT.child("highlight"); scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| { let anchor = below(ui, split); - ui::popup::colors(ui, id, anchor, "No colour", &swatches, 5); + ui::popup::colors(ui, id, anchor, "No Color", &swatches, 5); }); } @@ -601,14 +768,34 @@ fn palette(scene: &mut Scene) { fn tags(scene: &mut Scene) { let tags = [ ("To Do", None::<&'static [&'static str]>, "⌘1"), - ("Important", Some(&[include_str!("../../canvas/assets/tags/star.svg")]), "⌘2"), + ( + "Important", + Some(&[include_str!("../../canvas/assets/tags/star.svg")]), + "⌘2", + ), ("Question", None, "⌘3"), ("Remember for later", Some(art!("tags/remember")), "⌘4"), ("Definition", Some(art!("tags/definition")), "⌘5"), - ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"), - ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"), - ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"), - ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"), + ( + "Highlight", + Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), + "⌘6", + ), + ( + "Contact", + Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), + "⌘7", + ), + ( + "Address", + Some(&[include_str!("../../canvas/assets/tags/address.svg")]), + "⌘8", + ), + ( + "Phone number", + Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), + "⌘9", + ), ("Web site to visit", None, ""), ("Idea", None, ""), ("Password", None, ""), @@ -686,13 +873,7 @@ fn paint( let layers: Vec<_> = interface .iter() .filter_map(|layer| match layer { - ui::Layer::Primitives { clip, primitives } => Some(draw::Layer { - scale: SCALE, - origin: [0.0; 2], - clip: clip.map(|clip| clip.map(|value| value * SCALE)), - backdrop: None, - primitives, - }), + ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)), ui::Layer::Custom { .. } => None, }) .collect(); diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 635af82eb6c7271663c89035acf21782ea85ee99..47d2ace50b0de315fcc97f200c6610456687633b 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -1,12 +1,22 @@ -use crate::{Axis, Built, Flags, Id, Size, State}; +use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; use std::collections::HashMap; /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from /// ancestors (pre-order), sizes summed from children (post-order), overflow shared out -/// by strictness, and positions along each parent's flow. Boxes are in build order, so +/// by strictness, and positions along each parent's flow. Labels too wide for their +/// solved width wrap or shorten before heights are solved. Boxes are in build order, so /// index order is pre-order. -pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32) { +pub(crate) fn solve( + nodes: &mut [Built], + states: &HashMap, + scale: f32, + texts: &mut Texts, + frame: u64, +) { for axis in 0..2 { + if axis == 1 { + fit_labels(nodes, texts, frame); + } for node in nodes.iter_mut() { node.computed[axis] = match node.size[axis].size { Size::Pixels(pixels) => pixels, @@ -47,7 +57,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { continue; } - let room = node.computed[axis] - 2.0 * node.pad[axis]; + let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0); let children: Vec<_> = in_flow(nodes, index).collect(); if along(node, axis) { let excess = flow(nodes, index, axis) - room; @@ -110,8 +120,9 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32 } else { 0.0 }; - let x = parent.rect[0] + nodes[index].relative[0]; - let y = parent.rect[1] + nodes[index].relative[1] - scroll; + let [dx, dy] = nodes[index].offset; + let x = parent.rect[0] + nodes[index].relative[0] + dx; + let y = parent.rect[1] + nodes[index].relative[1] - scroll + dy; nodes[index].rect = [ snap(x), snap(y), @@ -145,3 +156,26 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { sizes.fold(0.0, f32::max) } } + +fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) { + for node in nodes.iter_mut() { + let Some(label) = node.label.clone() else { + continue; + }; + let icon = if node.icon.is_some() || node.image.is_some() { + ICON + ICON_GAP + } else { + 0.0 + }; + let [left, _, right, _] = node.inset; + let width = (node.computed[0] - left - right - 2.0 * node.pad[0] - icon).max(0.0); + if label.size[0] <= width { + continue; + } + node.label = match node.overflow { + Overflow::Clip => continue, + Overflow::Wrap => Some(label.wrapped(width, node.center)), + Overflow::Ellipsis => Some(texts.ellipsis(&label, width, frame)), + }; + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 943435fe3315aa0a77067d0e75181b27e207b909..d2bc912281ba83f4a893025fe1a25c2ea26ba913 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -43,6 +43,9 @@ const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; /// Logical size of a box's icon, and its distance from the label. const ICON: f32 = 16.0; const ICON_GAP: f32 = 6.0; +/// Seconds a dialog and other popups take to open and to close. +const DIALOG: [f32; 2] = [0.24, 0.16]; +const POPUP: [f32; 2] = [0.16, 0.12]; /// A box's identity across frames: its parent's id combined with a builder-chosen part. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -136,6 +139,9 @@ impl Flags { pub const FLOAT: Flags = Flags(16); /// The host paints it and receives the events routed to it. pub const CUSTOM: Flags = Flags(32); + /// Paints in place while the popup around it opens, as a field standing in for the one + /// it opened from. + pub const STILL: Flags = Flags(64); pub(crate) fn contains(self, other: Flags) -> bool { self.0 & other.0 == other.0 @@ -165,6 +171,18 @@ pub enum Shape { Tab { lean: f32 }, } +/// What a label does where it is wider than its box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Overflow { + /// Runs on as one line, cut at the box's edge. + #[default] + Clip, + /// Breaks into lines across the box, which grows to their height when sized by its text. + Wrap, + /// Stays one line, ending in an ellipsis where it is cut. + Ellipsis, +} + /// Where a popup opens, flipping to the far side of its anchor where the window ends first. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Anchor { @@ -174,8 +192,10 @@ pub enum Anchor { Right([f32; 4]), /// Over the rectangle from its corner, as a combo box opens into its own list. Over([f32; 4]), - /// At a point, as a context menu opens: at once, without fading in. + /// At a point, as a context menu opens. Point([f32; 2]), + /// Centred in the window over the interface, which dims, as a dialog opens. + Dialog, } impl Anchor { @@ -187,6 +207,7 @@ impl Anchor { Anchor::Right(rect) => (rect, Some(0)), Anchor::Over(rect) => (rect, None), Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), + Anchor::Dialog => return ((room - size) / 2.0).max(0.0), }; let [low, high] = [rect[axis], rect[axis + 2]]; let (first, second) = if along == Some(axis) { @@ -202,6 +223,51 @@ impl Anchor { first.min(room - size).max(0.0) } } + + /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it + /// grows out of the anchor as it fades in, and a dialog swings up into place. + fn motion(self, rect: [f32; 4], open: f32) -> Motion { + let pivot = match self { + Anchor::Below(anchor) => [anchor[0], anchor[3]], + Anchor::Right(anchor) => [anchor[2], anchor[1]], + Anchor::Over(anchor) => [anchor[0], anchor[1]], + Anchor::Point(point) => point, + Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]], + }; + let (from, tilt) = match self { + Anchor::Dialog => (0.95, 0.4), + Anchor::Over(_) => (0.94, 0.25), + _ => (0.94, 0.0), + }; + Motion { + zoom: from + (1.0 - from) * open, + pivot: [ + pivot[0].clamp(rect[0], rect[2]), + pivot[1].clamp(rect[1], rect[3]), + ], + tilt: tilt * (1.0 - open), + opacity: open, + } + } + + /// Seconds the popup takes to open and to close. + fn durations(self) -> [f32; 2] { + if self == Anchor::Dialog { + DIALOG + } else { + POPUP + } + } +} + +/// A box and what it holds scaled by `zoom` about `pivot`, leaned back `tilt` radians about +/// the line through it, and faded; logical pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Motion { + zoom: f32, + pivot: [f32; 2], + tilt: f32, + opacity: f32, } /// What a box is this frame. Colours are linear RGBA. @@ -218,6 +284,7 @@ pub struct Spec<'a> { /// Shapes the label semibold. pub bold: bool, pub font: Option<&'a str>, + pub overflow: Overflow, /// The label's colour; the theme's text colour otherwise. pub color: Option<[f32; 4]>, pub fill: Option<[f32; 4]>, @@ -246,6 +313,9 @@ pub struct Spec<'a> { pub center: bool, /// For floating boxes, the offset from the parent's corner. pub position: [f32; 2], + /// How far the box and its children draw and take the pointer from where they are laid + /// out, the boxes around them keeping their places, as a row sliding aside does. + pub offset: [f32; 2], pub cursor: Option, /// Floats the box over all others beside a rectangle in the window, as a popup /// `Ui::open_popup` opened; boxes beneath take no input while one is open. @@ -291,13 +361,9 @@ pub struct Signal { pub events: Vec, } -/// A frame's painting, in order: interface primitives under a clip, or a host-painted box. +/// A frame's painting, in order: interface primitives, or a host-painted box. pub enum Layer<'a> { - Primitives { - /// Logical pixels. - clip: Option<[f32; 4]>, - primitives: Vec>, - }, + Primitives(Primitives<'a>), Custom { id: Id, /// The box's visible logical rectangle. @@ -305,6 +371,52 @@ pub enum Layer<'a> { }, } +/// Interface primitives under a clip, in logical pixels, perhaps part of the way through +/// opening or closing. +pub struct Primitives<'a> { + pub clip: Option<[f32; 4]>, + motion: Option, + pub primitives: Vec>, +} + +impl Primitives<'_> { + /// The layer the renderer paints at `scale` device pixels per logical pixel. + pub fn layer(&self, scale: f32) -> draw::Layer<'_> { + let Motion { + zoom, + pivot, + tilt, + opacity, + } = self.motion.unwrap_or(Motion { + zoom: 1.0, + pivot: [0.0; 2], + tilt: 0.0, + opacity: 1.0, + }); + let device = |point: [f32; 2]| point.map(|value| value * scale); + let moved = |point: [f32; 2]| { + device(std::array::from_fn(|axis| { + pivot[axis] + (point[axis] - pivot[axis]) * zoom + })) + }; + draw::Layer { + scale: scale * zoom, + origin: moved([0.0; 2]), + clip: self.clip.map(|[left, top, right, bottom]| { + let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])]; + [left, top, right, bottom] + }), + backdrop: None, + motion: self.motion.map(|_| draw::Motion { + opacity, + tilt, + pivot: device(pivot), + }), + primitives: &self.primitives, + } + } +} + struct Built { id: Id, parent: usize, @@ -313,6 +425,7 @@ struct Built { size: [Extent; 2], axis: Axis, label: Option>, + overflow: Overflow, color: [f32; 4], fill: Option<[f32; 4]>, gradient: Option<[f32; 4]>, @@ -329,8 +442,10 @@ struct Built { gap: f32, center: bool, position: [f32; 2], + offset: [f32; 2], cursor: Option, anchor: Option, + motion: Option, /// Rectangles relative to the box, painted over its fill. marks: Vec<([f32; 4], [f32; 4], f32)>, computed: [f32; 2], @@ -353,6 +468,8 @@ struct State { /// A text field's selection, and the selection and unit of the press a drag extends. selection: Selection, press: (Selection, SelectionUnit), + /// The field selects all its text when next built. + select_all: bool, /// Where a dragged scrollbar thumb was taken, from its start. grab: f32, /// An animated value and its target, for `Ui::animate`. @@ -369,6 +486,16 @@ struct Popup { highlight: Option, /// The height its results ease from and to, and the seconds since they set out. height: Option<[f32; 3]>, + opened: Instant, +} + +/// A popup's painting as it last showed, fading out since it closed. +struct Closing { + id: Id, + display: Vec, + anchor: Anchor, + rect: [f32; 4], + closed: Instant, } struct Hit { @@ -378,8 +505,10 @@ struct Hit { cursor: Option, } +#[derive(Clone)] enum Display { Clip(Option<[f32; 4]>), + Motion(Option), Rect { rect: [f32; 4], fill: [f32; 4], @@ -425,6 +554,8 @@ enum Display { pub struct Ui { pub theme: Theme, + /// The colours icons' slots take. + pub icon_palette: draw::Palette, /// Whether the window has keyboard focus; without it a field hides its caret and dims /// its selection. pub window_focused: bool, @@ -456,6 +587,10 @@ pub struct Ui { display: Vec, /// Where the popups' painting starts in `display`, for edges drawn beneath them. popups_painted: usize, + /// Each popup painted, its anchor and rectangle, and its painting's place after + /// `popups_painted`. + painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range)>, + closing: Vec, animating: bool, /// This frame routed input, whose effects the builder may only have seen after boxes /// built before it read their state; one more frame shows them. @@ -471,6 +606,7 @@ impl Ui { pub fn new(theme: Theme, double_click: Duration) -> Self { Self { theme, + icon_palette: draw::Palette::default(), window_focused: true, frame: 0, now: Instant::now(), @@ -495,6 +631,8 @@ impl Ui { clicks: Clicks::new(double_click), display: Vec::new(), popups_painted: 0, + painted: Vec::new(), + closing: Vec::new(), animating: false, routed: false, caret: None, @@ -538,6 +676,15 @@ impl Ui { self.focus = id; } + /// Focuses text field `id` with all its text selected once it is built, as a field + /// opening to rename something does. + pub fn focus_all(&mut self, id: Id) { + let state = self.states.entry(id).or_default(); + state.touched = self.frame; + state.select_all = true; + self.focus = Some(id); + } + /// The clickable or custom box at `point` in the latest layout. pub fn box_at(&self, point: [f32; 2]) -> Option { self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) @@ -767,6 +914,7 @@ impl Ui { query: String::new(), highlight: None, height: None, + opened: self.now, }); self.focus = Some(id); self.states.entry(id).or_default().touched = self.frame; @@ -838,11 +986,10 @@ impl Ui { /// Opens a box under an id chosen outside the hierarchy, so the host can name it /// wherever it is built. pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { - let label = spec - .text - .map(|text| { + let label = spec.text.map(|text| { let size = spec.font_size.unwrap_or(self.theme.font_size); - self.texts.label(text, size, spec.bold, spec.font, self.frame) + self.texts + .label(text, size, spec.bold, spec.font, self.frame) }); // Popups hang from the root, outside the clips and flow of where they are built. let parent = if spec.anchor.is_some() { @@ -872,6 +1019,15 @@ impl Ui { } /// Eases towards `target` over the next frames, starting there the first time `id` asks. + /// Holds animated value `id` at `value` without easing, as a dragged box follows the + /// pointer; it eases from there once animated again. + pub fn hold(&mut self, id: Id, value: f32) -> f32 { + let state = self.states.entry(id).or_default(); + state.touched = self.frame; + state.tween = Some([value; 2]); + value + } + pub fn animate(&mut self, id: Id, target: f32) -> f32 { let state = self.states.entry(id).or_default(); state.touched = self.frame; @@ -881,12 +1037,6 @@ impl Ui { *value } - /// Shapes labels in the family the font `files` define, in place of the system's - /// interface font. Returns the family's name, or None when the files hold no font. - pub fn use_fonts(&mut self, files: impl IntoIterator>) -> Option { - self.texts.use_fonts(files) - } - /// Previews `family` in the font `data` holds, as a font menu shows the substitute a /// missing family lays out in. pub fn preview_font(&mut self, data: parley::fontique::Blob, family: &str) { @@ -937,7 +1087,13 @@ impl Ui { /// Solves this frame's layout and prepares its painting. pub fn end(&mut self) { debug_assert_eq!(self.stack.len(), 1, "open without close"); - layout::solve(&mut self.nodes, &self.states, self.scale); + layout::solve( + &mut self.nodes, + &self.states, + self.scale, + &mut self.texts, + self.frame, + ); for node in &self.nodes { let state = self.states.entry(node.id).or_default(); state.rect = node.rect; @@ -960,16 +1116,74 @@ impl Ui { { self.close_from(gone); } - self.display.clear(); + let last = std::mem::take(&mut self.display); + let last_popups = self.popups_painted; self.hits.clear(); - self.paint(0, None); + self.paint(0, None, None); self.popups_painted = self.display.len(); let beneath = self.hits.len(); + let mut painted = Vec::new(); for index in self.nodes[0].children.clone() { - if self.nodes[index].anchor.is_some() { - self.paint(index, None); + let node = &self.nodes[index]; + let Some(anchor) = node.anchor else { + continue; + }; + let (id, rect) = (node.id, node.rect); + if let Some(open) = self.opening(id, anchor) { + self.scrim(anchor, open); + self.nodes[index].motion = Some(anchor.motion(rect, open)); } + let from = self.display.len() - self.popups_painted; + self.paint(index, None, None); + painted.push(( + id, + anchor, + rect, + from..self.display.len() - self.popups_painted, + )); } + for (id, anchor, rect, range) in std::mem::replace(&mut self.painted, painted) { + if self.painted.iter().any(|(shown, ..)| *shown == id) { + continue; + } + // What held still while it opened goes at once, uncovering what it stood in for. + let mut still = false; + let display = last[last_popups + range.start..last_popups + range.end] + .iter() + .filter(|item| match item { + Display::Motion(motion) => { + still = motion.is_none(); + false + } + _ => !still, + }) + .cloned() + .collect(); + self.closing.push(Closing { + id, + display, + anchor, + rect, + closed: self.now, + }); + } + let closing = std::mem::take(&mut self.closing); + for closing in &closing { + let shut = self.progress(closing.closed, closing.anchor.durations()[1]); + let open = (1.0 - shut).powi(3); + self.scrim(closing.anchor, open); + let motion = closing.anchor.motion(closing.rect, open); + self.display.push(Display::Motion(Some(motion))); + self.display.extend(closing.display.iter().cloned()); + self.display.push(Display::Motion(None)); + } + self.closing = closing + .into_iter() + .filter(|closing| { + self.progress(closing.closed, closing.anchor.durations()[1]) < 1.0 + && !self.painted.iter().any(|(shown, ..)| *shown == closing.id) + }) + .collect(); self.modal = if self.popups.is_empty() { 0 } else { beneath }; for id in [&mut self.hover, &mut self.active, &mut self.focus] { if id.is_some_and(|id| !self.states.contains_key(&id)) { @@ -978,19 +1192,50 @@ impl Ui { } } - fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) { + /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open. + pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option { + let opened = self.popups.iter().find(|popup| popup.id == id)?.opened; + Some(1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3)) + } + + /// How far through an animation `duration` seconds long that began at `start` this + /// frame is, asking for frames until it ends. + fn progress(&mut self, start: Instant, duration: f32) -> f32 { + let progress = + (self.now.saturating_duration_since(start).as_secs_f32() / duration).min(1.0); + self.animating |= progress < 1.0; + progress + } + + /// Dims the window beneath a dialog `open` of the way open. + fn scrim(&mut self, anchor: Anchor, open: f32) { + if anchor == Anchor::Dialog { + self.display.push(Display::Rect { + rect: self.nodes[0].rect, + fill: [0.0, 0.0, 0.0, self.theme.shadow[3] * 0.5 * open], + shade: None, + border: None, + radius: 0.0, + }); + } + } + + fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option) { let node = &self.nodes[index]; + // The motion the box and its children paint with, where it changes it. + let own = if node.flags.contains(Flags::STILL) { + Some(None) + } else { + node.motion.map(Some) + }; + if let Some(own) = own { + self.display.push(Display::Motion(own)); + } let state = &self.states[&node.id]; let rect = node.rect; let visible = clip.map_or(Some(rect), |clip| intersect(rect, clip)); - // Without a colour of its own, a box fades the hover colour in rather than - // blending from transparent black. let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) { - (base, Some(hover)) => Some(mix( - base.unwrap_or([hover[0], hover[1], hover[2], 0.0]), - hover, - state.hot, - )), + (base, Some(hover)) => Some(mix(base.unwrap_or_default(), hover, state.hot)), (base, None) => base, }; let fill = blend(node.fill, node.hover_fill); @@ -1085,7 +1330,12 @@ impl Ui { let rect = if node.label.is_some() { [x, top, x + ICON, top + ICON] } else { - [inner[0], painted[1] + node.pad[1], inner[2], painted[3] - node.pad[1]] + [ + inner[0], + painted[1] + node.pad[1], + inner[2], + painted[3] - node.pad[1], + ] }; self.display.push(Display::Image { image: image.clone(), @@ -1130,30 +1380,47 @@ impl Ui { } for child in node.children.clone() { if self.nodes[child].anchor.is_none() { - self.paint(child, inner_clip); + self.paint(child, inner_clip, own.unwrap_or(motion)); } } if inner_clip != clip { self.display.push(Display::Clip(clip)); } + if own.is_some() { + self.display.push(Display::Motion(motion)); + } } /// This frame's painting in order, in logical pixels. pub fn layers(&self) -> Vec> { let mut layers = Vec::new(); let mut clip = None; + let mut motion = None; let mut primitives = Vec::new(); + fn flush<'a>( + layers: &mut Vec>, + clip: Option<[f32; 4]>, + motion: Option, + primitives: &mut Vec>, + ) { + if !primitives.is_empty() { + layers.push(Layer::Primitives(Primitives { + clip, + motion, + primitives: std::mem::take(primitives), + })); + } + } for item in &self.display { match item { Display::Clip(next) => { - if !primitives.is_empty() { - layers.push(Layer::Primitives { - clip, - primitives: std::mem::take(&mut primitives), - }); - } + flush(&mut layers, clip, motion, &mut primitives); clip = *next; } + Display::Motion(next) => { + flush(&mut layers, clip, motion, &mut primitives); + motion = *next; + } Display::Rect { rect, fill, @@ -1221,6 +1488,7 @@ impl Ui { origin: *origin, size: ICON, tint: *tint, + palette: self.icon_palette, }), Display::Text { label, @@ -1234,12 +1502,7 @@ impl Ui { ink: *color, }), Display::Custom { id, rect } => { - if !primitives.is_empty() { - layers.push(Layer::Primitives { - clip, - primitives: std::mem::take(&mut primitives), - }); - } + flush(&mut layers, clip, motion, &mut primitives); layers.push(Layer::Custom { id: *id, rect: *rect, @@ -1247,9 +1510,7 @@ impl Ui { } } } - if !primitives.is_empty() { - layers.push(Layer::Primitives { clip, primitives }); - } + flush(&mut layers, clip, motion, &mut primitives); layers } @@ -1257,9 +1518,16 @@ impl Ui { (&mut self.texts, self.frame) } - pub(crate) fn field(&mut self, id: Id) -> (&mut Selection, &mut (Selection, SelectionUnit)) { + pub(crate) fn field( + &mut self, + id: Id, + ) -> (&mut Selection, &mut (Selection, SelectionUnit), &mut bool) { let state = self.states.entry(id).or_default(); - (&mut state.selection, &mut state.press) + ( + &mut state.selection, + &mut state.press, + &mut state.select_all, + ) } pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { @@ -1293,6 +1561,7 @@ impl Built { size: spec.size, axis: spec.axis, label, + overflow: spec.overflow, color: spec.color.unwrap_or(text), fill: spec.fill, gradient: spec.gradient, @@ -1309,8 +1578,10 @@ impl Built { gap: spec.gap, center: spec.center, position: spec.position, + offset: spec.offset, cursor: spec.cursor, anchor: spec.anchor, + motion: None, marks: Vec::new(), computed: [0.0; 2], relative: [0.0; 2], @@ -1407,9 +1678,38 @@ impl Corner { } } -/// `a` moved `t` of the way to `b`. +/// Where a box dragged from index `from` of a list laid out at `spans`, each a start and a +/// length along it, lands with its middle at `middle`: after every other box whose middle it +/// has passed, as browser tabs reorder. The index is in the list's new order. +pub fn drop_slot(spans: &[[f32; 2]], from: usize, middle: f32) -> usize { + spans + .iter() + .enumerate() + .filter(|(index, [start, length])| *index != from && start + length / 2.0 < middle) + .count() +} + +/// How far box `index` of a list slides aside while the box at `from`, `length` long, is +/// dragged to land at `to`. +pub fn slide(index: usize, from: usize, to: usize, length: f32) -> f32 { + if from < index && index <= to { + -length + } else if to <= index && index < from { + length + } else { + 0.0 + } +} + +/// `a` moved `t` of the way to `b`, mixed as premultiplied colours, so from transparent +/// `b` fades in with its own hue. pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { - std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) + let alpha = a[3] + (b[3] - a[3]) * t; + if alpha == 0.0 { + return [0.0; 4]; + } + let channel = |i: usize| (a[i] * a[3] + (b[i] * b[3] - a[i] * a[3]) * t) / alpha; + [channel(0), channel(1), channel(2), alpha] } fn contains(rect: [f32; 4], point: [f32; 2]) -> bool { diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 69906deca6798e0d18175c802ea7c21dc2caee48..27d56c60772e7b584425c9e041c7668857046c34 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -4,8 +4,6 @@ use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; use winit::keyboard::NamedKey; -/// How far rows fade out under a scrolled list's edges. -const EDGE: f64 = 10.0; /// Room beside a scrolling list's rows for its scrollbar. pub(crate) const GUTTER: f32 = 12.0; @@ -191,8 +189,6 @@ pub fn list( let scroll = state.scroll; ui.animating |= scroll != state.target; - let theme = ui.theme.clone(); - let background = spec.fill.unwrap_or(theme.base); ui.open_as( id, Spec { @@ -235,28 +231,7 @@ pub fn list( ui.close(); shown.push((key, place)); } - // Rows fade out under the list's edges rather than end at a cut. - let clear = [background[0], background[1], background[2], 0.0]; - let edges = [ - (scroll > 0.0, 0.0, [background, clear]), - (scroll < most, view - EDGE, [clear, background]), - ]; - for (index, (shown, at, [top, bottom])) in edges.into_iter().enumerate() { - if shown { - ui.leaf( - ("edge", index), - Spec { - flags: Flags::FLOAT, - size: [width, px(EDGE as f32)], - position: [0.0, at as f32], - fill: Some(top), - gradient: Some(bottom), - ..Spec::default() - }, - ); - } - } - let thumb = mix(theme.text_dim, theme.chip, 0.5); + let thumb = mix(ui.theme.text_dim, ui.theme.chip, 0.5); if let Some(offset) = scrollbar( ui, "bar", diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 89d30c36396bfc0cd5f51d3cff418bdc9702299f..9a5b4fddce88cb899e178a17c7d6a085b2f1ac69 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -1,5 +1,5 @@ -//! Controls built on the popup layer: menus and filterable lists, a colour grid and a -//! command palette. Each builds while its popup is open and returns what was chosen the +//! Controls built on the popup layer: menus and filterable lists, a colour grid, a gallery +//! and a command palette. Each builds while its popup is open and returns what was chosen the //! frame it is, closing the popup. use crate::{ @@ -16,7 +16,13 @@ use winit::keyboard::{Key, NamedKey}; pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; /// Inset of a popup's contents, and its distance from what it drops down from. const PAD: f32 = 4.0; +/// A palette's rows and filter field, and a menu's more compact rows. const ROW: f32 = 26.0; +pub(crate) const MENU_ROW: f32 = 22.0; +/// Inset of a menu row's icon and text. +const ROW_PAD: f32 = 8.0; +/// How far a menu's icons sit inside its leading edge. +pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD; /// Rows a list shows before it scrolls. const ROWS: f32 = 12.0; /// Height of the rule between groups. @@ -108,6 +114,7 @@ pub fn menu( items, filter, width.max(least).max(NARROWEST), + MENU_ROW, ) } @@ -128,6 +135,7 @@ pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option { commands, Some("Search commands"), width, + ROW, ) } @@ -138,6 +146,7 @@ fn choose( items: &[Item], filter: Option<&str>, width: f32, + row: f32, ) -> Option { let field = id.child("filter"); if filter.is_some() && ui.focus == Some(id) { @@ -148,21 +157,31 @@ fn choose( let mut query = std::mem::take(&mut popup.query); let mut highlight = popup.highlight; let theme = ui.theme.clone(); + // Over a combo box, the field takes the box's place at once and widens as the popup + // comes in around it. + let (flags, size) = match anchor { + Anchor::Over(rect) => { + let open = ui.opening(id, anchor).unwrap_or(1.0); + let from = rect[2] - rect[0]; + let to = width - 2.0 * PAD; + ( + Flags::STILL, + [px(from + (to - from) * open), px(rect[3] - rect[1])], + ) + } + _ => (Flags::default(), [fill(), px(ROW)]), + }; surface(ui, id, anchor, width); if let Some(placeholder) = filter { let before = query.clone(); - // Over a combo box, the field takes the box's place. - let height = match anchor { - Anchor::Over(rect) => rect[3] - rect[1], - _ => ROW, - }; text_field( ui, field, &mut query, placeholder, Spec { - size: [fill(), px(height)], + flags, + size, fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, @@ -178,7 +197,10 @@ fn choose( // Unfiltered, the current or checked item starts highlighted; filtered, the best match. highlight = highlight.or_else(|| { let first = if query.is_empty() { - matches.order.iter().position(|index| items[*index].current || items[*index].checked) + matches + .order + .iter() + .position(|index| items[*index].current || items[*index].checked) } else { (0..matches.count()).find(|row| matches.selectable(*row)) }; @@ -186,10 +208,10 @@ fn choose( }); let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); let field = if filter.is_some() { ROW + PAD } else { 0.0 }; - let content = matches.count() as f32 * ROW + matches.space_before(matches.count()); + let content = matches.count() as f32 * row + matches.space_before(matches.count()); let view = content - .max(ROW) - .min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW)); + .max(row) + .min((ROWS * row).min(window - 4.0 * PAD - field).max(row)); // The popup eases to its new height as the results change; the rows do not move. let dt = ui.dt; let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]); @@ -212,7 +234,7 @@ fn choose( ui.leaf( "empty", Spec { - size: [fill(), px(ROW)], + size: [fill(), px(row)], text: Some("No matches"), color: Some(theme.text_dim), pad: [8.0, 0.0], @@ -223,7 +245,7 @@ fn choose( } else { let list = List { rows: &matches, - row: ROW, + row, keys: &keys, hover_selects: true, }; @@ -285,7 +307,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { size: [fill(), fill()], fill: row.selected.then(|| theme.hover()), radius: 4.0, - pad: [8.0, 0.0], + pad: [ROW_PAD, 0.0], gap: ICON_GAP, ..Spec::default() }, @@ -299,7 +321,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { ui.leaf( "icon", Spec { - size: [px(ICON), px(ROW)], + size: [px(ICON), fill()], icon: if item.checked { Some(CHECK) } else { item.icon }, color: Some(if item.disabled { mix(tint, theme.popup, 0.5) @@ -313,7 +335,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { ui.leaf( "text", Spec { - size: [fill(), px(ROW)], + size: [fill(), fill()], text: Some(item.text), font: item.font, font_size: item.heading.then_some(theme.font_size - 2.0), @@ -326,7 +348,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { ui.leaf( "shortcut", Spec { - size: [fit(), px(ROW)], + size: [fit(), fill()], text: Some(item.shortcut), color: Some(theme.text_dim), ..Spec::default() @@ -451,6 +473,115 @@ pub fn colors( None } +/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each +/// a heading and a count, in rows of `columns` cells `size` large, with cell `current` +/// outlined. `cell` builds a cell's contents from its index, counted through the groups. +/// Returns the index of the cell chosen. +#[allow(clippy::too_many_arguments)] +pub fn gallery( + ui: &mut Ui, + id: Id, + anchor: Anchor, + groups: &[(&str, usize)], + columns: usize, + size: [f32; 2], + current: Option, + mut cell: impl FnMut(&mut Ui, usize), +) -> Option { + if !ui.popup_open(id) { + return None; + } + let cell_id = |index: usize| id.child(("cell", index)); + let count: usize = groups.iter().map(|(_, count)| count).sum(); + let keys = navigation( + ui, + &[id], + &[ + NamedKey::ArrowLeft, + NamedKey::ArrowRight, + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Enter, + ], + ); + let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); + let mut chosen = None; + for index in 0..count { + let signal = ui.signal(cell_id(index)); + if signal.hovered && ui.moved { + highlight = Some(index); + } + if signal.clicked { + chosen = Some(index); + } + } + for key in keys { + let at = highlight.or(current).unwrap_or(0); + highlight = Some(match key { + NamedKey::Enter => { + chosen = chosen.or(highlight); + continue; + } + NamedKey::ArrowLeft => at.saturating_sub(1), + NamedKey::ArrowRight => (at + 1).min(count - 1), + NamedKey::ArrowUp => at.saturating_sub(columns), + _ => (at + columns).min(count - 1), + }); + } + if let Some(index) = chosen { + ui.close_popup(id); + return Some(index); + } + let theme = ui.theme.clone(); + surface(ui, id, anchor, columns as f32 * size[0] + 2.0 * PAD); + let mut index = 0; + for (group, (heading, cells)) in groups.iter().enumerate() { + ui.leaf( + ("heading", group), + Spec { + size: [fill(), px(MENU_ROW)], + text: Some(heading), + font_size: Some(theme.font_size - 2.0), + bold: true, + color: Some(theme.text_dim), + pad: [PAD, 0.0], + ..Spec::default() + }, + ); + for row in 0..cells.div_ceil(columns) { + ui.open( + ("row", group, row), + Spec { + size: [fill(), px(size[1])], + ..Spec::default() + }, + ); + for _ in 0..columns.min(cells - row * columns) { + ui.open_as( + cell_id(index), + Spec { + flags: Flags::CLICKABLE, + axis: Axis::Y, + size: [px(size[0]), px(size[1])], + fill: (highlight == Some(index)).then(|| theme.hover()), + border: (current == Some(index)).then_some(theme.accent), + radius: 4.0, + pad: [PAD, PAD], + ..Spec::default() + }, + ); + cell(ui, index); + ui.close(); + index += 1; + } + ui.close(); + } + } + ui.close(); + state(ui, id).highlight = highlight.map(|cell| cell as u64); + None +} + /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { let anchor = match anchor { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 0334c893756677771ca9d28cd2a52682e447e309..d2a39ec6547b7f45f8430ae5e44ccd1208c50337 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1,10 +1,10 @@ //! Controls a notebook window's chrome is built from: OneNote's section tabs and the //! toolbar's compact buttons. -use draw::PathStyle; +use draw::{PathStyle, edit::SelectionUnit}; use crate::{ - Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px, + Anchor, Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px, }; use std::hash::Hash; @@ -14,17 +14,25 @@ pub const CHEVRON: &[&str] = &[include_str!("../assets/chevron-down.svg")]; const TAB_ROUNDING: f32 = 4.0; /// Side of a toolbar button. pub const TOOL: f32 = 22.0; +/// Width of a menu arrow beside a toolbar button. +const ARROW: f32 = 10.0; +/// Where a section tab's label starts inside it. +pub const TAB_PAD: f32 = 10.0; /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame /// below as its outline and shadow fade in. `tabs` are names and section colours; `lit` -/// shows as hovered, as a tab something is dragged onto. +/// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the +/// pointer, lifted over the others, which slide aside to open the gap it would land in; +/// each eases to its place once let go. +#[allow(clippy::too_many_arguments)] pub fn section_tabs( ui: &mut Ui, part: impl Hash, tabs: &[(&str, [f32; 4])], active: usize, lit: Option, + dragged: Option, section: &Section, height: f32, ) -> Tabs { @@ -38,25 +46,60 @@ pub fn section_tabs( }, ); let [low, tallest] = [height - 6.0, height - 2.0]; - let mut left = 0.0; + // Room for the first tab's outline and shadow, which the row would clip. + let mut left = crate::SHADOW[0]; let placed: Vec<_> = tabs .iter() .map(|(name, _)| { - let width = 10.0 + ui.measure(name)[0] + 4.0 + lean(height); + let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height); left += width; (left - width, width) }) .collect(); - let (mut clicked, mut context, mut held) = (None, None, None); - // Earlier tabs lie over later ones; the open tab over all. + let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None); + let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect(); + let slot = dragged.and_then(|dragged| { + let width = placed[dragged.index].1; + Some(crate::drop_slot( + &spans, + dragged.index, + dragged.start? + width / 2.0, + )) + }); + let lifted = dragged.map(|dragged| dragged.index); + let mut settled = true; + // Earlier tabs lie over later ones; the open tab over them, and a dragged one over all. let order = (0..tabs.len()) .rev() - .filter(|index| *index != active) - .chain((active < tabs.len()).then_some(active)); + .filter(|index| *index != active && Some(*index) != lifted) + .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) + .chain(lifted); for index in order { let (name, color) = tabs[index]; let (x, width) = placed[index]; - let open = ui.animate(ui.id(("open", index)), f32::from(u8::from(index == active))); + // Keyed by name, so a tab eases from where it stood when the tabs are reordered. + let key = ui.id(("x", name)); + let x = match (dragged, slot) { + ( + Some(Dragged { + start: Some(start), .. + }), + _, + ) if lifted == Some(index) => ui.hold(key, start), + (Some(dragged), Some(slot)) => ui.animate( + key, + x + crate::slide(index, dragged.index, slot, placed[dragged.index].1), + ), + _ => { + let eased = ui.animate(key, x); + settled &= lifted != Some(index) || (eased - x).abs() < 0.5; + eased + } + }; + let lift = f32::from(u8::from(lifted == Some(index))); + let open = ui + .animate(ui.id(("open", name)), f32::from(u8::from(index == active))) + .max(lift); // Whole device pixels, so a rising tab reuses its rasterized outlines. let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale(); let colors = theme.section(color); @@ -77,11 +120,12 @@ pub fn section_tabs( }), gradient: Some(fill), hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)), - border: Some(fade(section.edge, open)), - shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)), + // A tab not open is outlined in its own hue, fainter than the open one's. + border: Some(mix(mix(colors.tab, colors.edge, 0.55), section.edge, open)), + shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35 + 0.15 * lift], open)), radius: TAB_ROUNDING, shape: Shape::Tab { lean: lean(height) }, - pad: [10.0, 0.0], + pad: [TAB_PAD, 0.0], ..Spec::default() }, ); @@ -94,6 +138,9 @@ pub fn section_tabs( if signal.dragging { held = Some(index); } + if signal.pressed && signal.unit != SelectionUnit::Grapheme { + renamed = Some(index); + } } let open_tab = ui.id(("tab", active)); ui.close(); @@ -101,10 +148,21 @@ pub fn section_tabs( clicked, context, held, + renamed, + slot, + settled, open: open_tab, } } +/// A section tab being dragged: its index, and its leading edge along the row while it +/// follows the pointer; none once let go, as it eases into its place over the others. +#[derive(Clone, Copy, Debug)] +pub struct Dragged { + pub index: usize, + pub start: Option, +} + /// The id of tab `index` of the section tabs built as `row`. pub fn tab_id(row: Id, index: usize) -> Id { row.child(("tab", index)) @@ -117,6 +175,12 @@ pub struct Tabs { pub context: Option<(usize, [f32; 2])>, /// The tab held down, which a drag moves. pub held: Option, + /// The tab pressed twice in a row, which OneNote renames in place. + pub renamed: Option, + /// Where the dragged tab would land, as an index in the tabs' new order. + pub slot: Option, + /// The dragged tab, let go, stands in its place. + pub settled: bool, /// The open tab's id, for `tab_base`. pub open: Id, } @@ -158,34 +222,62 @@ pub fn tool_button( ) } -/// A button showing `icon`, lit while `on`, with a menu arrow beside it, and optionally -/// the colour it applies as a bar under the icon. Returns the button's and the arrow's -/// signals. +/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`, +/// with the colour it applies as a bar under the icon when given. Hovering the button fills +/// it alone; hovering the arrow, or its menu being open, outlines both as one control. +/// Returns the button's signal. pub fn split_button( ui: &mut Ui, part: impl Hash, icon: &'static [&'static str], bar: Option<[f32; 4]>, on: bool, -) -> [Signal; 2] { - let theme = ui.theme.clone(); - ui.open( - part, + menu: Id, +) -> Signal { + const RADIUS: f32 = 4.0; + let hover = ui.theme.hover(); + let fade = + |alpha: f32| (alpha > 0.0).then_some([hover[0], hover[1], hover[2], hover[3] * alpha]); + let split = ui.id(part); + let [button, arrow] = [split.child("button"), split.child("menu")]; + let lit = ui.animate( + split.child("lit"), + f32::from(u8::from(on || ui.hover == Some(button))), + ); + let outlined = ui.hover == Some(arrow) || ui.popup_open(menu); + let ring = ui.animate(split.child("ring"), f32::from(u8::from(outlined))); + ui.open_as( + split, Spec { size: [children(), px(TOOL)], ..Spec::default() }, ); - let button = ui.open( - "button", + ui.open_as( + button, + Spec { + flags: Flags::CLICKABLE | Flags::CLIP, + size: [px(TOOL), px(TOOL)], + ..Spec::default() + }, + ); + // Rounded past the clip, so the fill meets the arrow square. + ui.leaf( + "face", + Spec { + flags: Flags::FLOAT, + size: [px(TOOL + RADIUS), px(TOOL)], + fill: fade(lit), + radius: RADIUS, + ..Spec::default() + }, + ); + ui.open( + "icon", Spec { - flags: Flags::CLICKABLE, size: [px(TOOL), px(TOOL)], icon: Some(icon), - color: Some(theme.text), - fill: on.then(|| theme.hover()), - hover_fill: Some(theme.hover()), - radius: 4.0, + color: Some(ui.theme.text), center: true, ..Spec::default() }, @@ -196,21 +288,79 @@ pub fn split_button( ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0); } ui.close(); - let menu = ui.leaf( - "menu", + ui.close(); + if ui + .leaf( + "menu", + Spec { + flags: Flags::CLICKABLE, + size: [px(ARROW), px(TOOL)], + icon: Some(CHEVRON), + color: Some(ui.theme.text_dim), + center: true, + ..Spec::default() + }, + ) + .pressed + { + ui.open_popup(menu); + } + ui.leaf( + "ring", + Spec { + flags: Flags::FLOAT, + size: [px(TOOL + ARROW), px(TOOL)], + border: fade(ring), + radius: RADIUS, + ..Spec::default() + }, + ); + ui.close(); + ui.signal(button) +} + +/// A button showing `icon` and a menu arrow, one control that opens popup `menu`. Returns +/// where the menu opens so its icons line up under the button's. +pub fn menu_button( + ui: &mut Ui, + part: impl Hash, + icon: &'static [&'static str], + menu: Id, +) -> Anchor { + let theme = ui.theme.clone(); + let id = ui.open( + part, Spec { flags: Flags::CLICKABLE, - size: [px(10.0), px(TOOL)], - icon: Some(CHEVRON), - color: Some(theme.text_dim), + size: [px(TOOL + ARROW), px(TOOL)], + fill: ui.popup_open(menu).then(|| theme.hover()), hover_fill: Some(theme.hover()), radius: 4.0, - center: true, ..Spec::default() }, ); + for (part, icon, width, color) in [ + ("icon", icon, TOOL, theme.text), + ("arrow", CHEVRON, ARROW, theme.text_dim), + ] { + ui.leaf( + part, + Spec { + size: [px(width), px(TOOL)], + icon: Some(icon), + color: Some(color), + center: true, + ..Spec::default() + }, + ); + } ui.close(); - [ui.signal(button), menu] + if ui.signal(id).pressed { + ui.open_popup(menu); + } + let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); + let shift = crate::popup::ICON_INSET - (TOOL - crate::ICON) / 2.0; + Anchor::Below([left - shift, top, right - shift, bottom]) } /// A drop-down box `width` wide showing `text`. @@ -295,7 +445,8 @@ impl Ui { /// Rounds the corners of the clockwise outline `points`, as `border` does: paints /// `outside(y)` beyond each outward corner at height `y`, and `inside` into each - /// inward one. Called between `end` and `layers`. + /// inward one; a transparent colour cuts the corner out to the window's backdrop. + /// Called between `end` and `layers`. pub fn round_corners( &mut self, points: &[([f32; 2], f32)], @@ -311,6 +462,16 @@ impl Ui { corner.start[0] - corner.point[0], corner.start[1] - corner.point[1], ]; + let color = if corner.outward { + outside(corner.point[1]) + } else { + inside + }; + let (style, color) = if color[3] == 0.0 { + (PathStyle::Erase, [0.0, 0.0, 0.0, 1.0]) + } else { + (PathStyle::Fill, color) + }; self.beneath_popups(Display::Path { data: format!( "M{} {}{}L0 0Z", @@ -319,12 +480,8 @@ impl Ui { corner.curve(corner.point) ), origin: corner.point, - style: PathStyle::Fill, - colors: [if corner.outward { - outside(corner.point[1]) - } else { - inside - }; 2], + style, + colors: [color; 2], }); } } diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 4e2f0b10fa285abc6d826fa64f7c5f4f9eda2ecf..49ddfaa4a61eff58962bed9db000659b8151db3b 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -61,6 +61,36 @@ fn fill_yields_to_strict_siblings_along_the_flow() { assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0])); } +#[test] +fn padding_wider_than_its_box_leaves_loose_children_empty_not_negative() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut child = None; + frame(&mut ui, |ui| { + ui.open( + "cramped", + Spec { + size: [px(10.0), px(10.0)], + pad: [8.0, 8.0], + ..Spec::default() + }, + ); + let loose = Extent { + size: Size::Pixels(20.0), + strictness: 0.0, + }; + child = Some(ui.open( + "child", + Spec { + size: [loose; 2], + ..Spec::default() + }, + )); + ui.close(); + ui.close(); + }); + assert_eq!(ui.rect(child.unwrap()), Some([8.0, 8.0, 8.0, 8.0])); +} + #[test] fn children_sum_with_gaps_and_padding() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -327,10 +357,10 @@ fn wheel_scrolls_within_the_content_and_clips_children() { let layers = ui.layers(); assert!(matches!( layers[..], - [Layer::Primitives { + [Layer::Primitives(Primitives { clip: Some([0.0, 0.0, 100.0, 100.0]), .. - }] | [] + })] | [] )); } @@ -424,6 +454,20 @@ fn text_fields_edit_with_keys_and_selection() { assert_eq!(text, ""); } +#[test] +fn a_field_focused_whole_selects_its_text_once_for_typing_over() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut text = "New Section 1".to_owned(); + ui.focus_all(field()); + field_frame(&mut ui, &mut text); + ui.event(typed("K")); + field_frame(&mut ui, &mut text); + ui.event(typed("i")); + let signal = field_frame(&mut ui, &mut text); + assert!(signal.focused); + assert_eq!(text, "Ki"); +} + #[test] #[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")] fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { @@ -448,7 +492,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { .layers() .iter() .flat_map(|layer| match layer { - Layer::Primitives { primitives, .. } => primitives.as_slice(), + Layer::Primitives(layer) => layer.primitives.as_slice(), Layer::Custom { .. } => &[], }) .find_map(|primitive| match primitive { @@ -467,9 +511,16 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { assert_eq!(at(&mut ui, 700).0, faded); assert_eq!(at(&mut ui, 900).0, 0.0); ui.event(key(NamedKey::ArrowRight)); - assert_eq!(at(&mut ui, 910), (1.0, Some(Duration::from_micros(1_597_500)))); + assert_eq!( + at(&mut ui, 910), + (1.0, Some(Duration::from_micros(1_597_500))) + ); ui.window_focused = false; - assert_eq!(at(&mut ui, 920), (0.0, None), "an inactive window shows no caret"); + assert_eq!( + at(&mut ui, 920), + (0.0, None), + "an inactive window shows no caret" + ); } #[test] @@ -1397,8 +1448,9 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); build(&mut ui); ui.open_popup(menu_id()); - build(&mut ui); - build(&mut ui); + for _ in 0..12 { + build(&mut ui); + } let results = menu_id().child("results"); let height = |ui: &Ui| { let rect = ui.rect(results).unwrap(); @@ -1406,7 +1458,7 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { }; let tall = height(&ui); assert!( - !ui.wants_frame() && tall > 5.0 * 26.0, + !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW, "open at its full height" ); ui.event(typed("tnr")); @@ -1419,13 +1471,556 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { "Times New Roman at the top" ); let easing = height(&ui); - assert!(easing > 26.0 && easing < tall, "{easing}"); + assert!(easing > popup::MENU_ROW && easing < tall, "{easing}"); let mut frames = 0; while ui.wants_frame() { build(&mut ui); frames += 1; assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row)); } - assert_eq!(height(&ui), 26.0); + assert_eq!(height(&ui), popup::MENU_ROW); assert!(frames <= 10, "settles in {frames} frames"); } + +/// A column `width` wide holding one label: its box, and the label as laid out. +fn fitted( + ui: &mut Ui, + text: &str, + width: f32, + overflow: Overflow, + center: bool, +) -> ([f32; 4], Rc